diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index 194857f91..cd01f268b 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -23,8 +23,10 @@ Plan: `.claude/plans/gremlin-composable-algebra-v1.md`. | 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()` | Shipped (#1514; ConflictingUniverse fix follows) | 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-4b | edge table as the anchor (`E()`, M:N one hop, `outE/inV`) + `Fold::Distinct` (`out(rel).dedup().count()`) | In PR | bag and set == row loop over the text at n ∈ {1,64,65,4097}; unordered key lane refused (`LaneNotOrdered`); 7 disable runs red | +| D-CMH-G1-4 | rest: a second hop (blocked on the chained-hop decision), `V(parent).in(rel)` spelling, `bothE` | Queued | — | | D-CMH-G1-5 | FirstOfRun | Queued | insufficiency proof first; Quack + R2IL vs independent oracle | +| D-CMH-G1-6 | ordered `I32` conditions + `MIN`/`MAX` folds as method steps; R2IL `MIN`/`MAX` wired | In PR | both frontends == row loop incl. empty selection; 8 disable runs red | ## D-NALP — `NAL::narsese()` polyfill over R2IL procedures and mask-risc (2026-10-12) diff --git a/.claude/board/entries/2026-10-12-edge-table-anchor-distinct.md b/.claude/board/entries/2026-10-12-edge-table-anchor-distinct.md new file mode 100644 index 000000000..11721fe0c --- /dev/null +++ b/.claude/board/entries/2026-10-12-edge-table-anchor-distinct.md @@ -0,0 +1,19 @@ +# 2026-10-12 — D-CMH-G1-4b: an edge table as the anchor, and distinct targets + +**Status:** In PR. **Plan:** `.claude/plans/gremlin-composable-algebra-v1.md` §10. + +An M:N relation is its own concept with a functional relation to each end, so +each edge row is one traverser: `out(end).count()` is the bag and the new +`Fold::Distinct` (`out(end).dedup().count()`) the set. Both ends are filtered at +the source through the key; no program produces a mask another reads. +`Fold::Distinct` lowers to the liveness semijoin plus +`CountDistinctOrderedU32` (two words of state) and needs the key lane in key +order; an unordered lane is refused (`LaneNotOrdered`), never miscounted. + +MEASURED (tests/distinct.rs, row loop over field text, n ∈ {1,64,65,4097}): +bag and set equal the oracle and differ on this data; seven disable runs red on +the test that names each guard. + +OPEN: a second hop stays refused. It needs the chained-hop decision +(`2026-10-03-coresearch-chained-hop.md`): every multi-hop question in +#1510/#1512/#1515 waits on it. diff --git a/.claude/board/entries/2026-10-12-ordered-conditions-and-extrema.md b/.claude/board/entries/2026-10-12-ordered-conditions-and-extrema.md new file mode 100644 index 000000000..5b290f591 --- /dev/null +++ b/.claude/board/entries/2026-10-12-ordered-conditions-and-extrema.md @@ -0,0 +1,20 @@ +# 2026-10-12 — D-CMH-G1-6: ordered I32 conditions and MIN/MAX as method steps + +**Status:** In PR. **Plan:** `.claude/plans/gremlin-composable-algebra-v1.md` §9. + +`Cond::Order` (<, <=, >, >= on an I32 field, in lane units) and +`Fold::Min/Max` reach both frontends. R2IL's `MIN`/`MAX` fold calls, defined +in OGAR but refused as unimplemented here, are now wired onto mask-risc's +MaskedMinI32/MaxI32. No new opcode anywhere. + +MEASURED: `tests/ordered.rs`, both frontends equal a row loop over the +indicator text at n ∈ {1,64,65,4097}, for every comparison at five +thresholds including an empty selection. Eight disable runs red. + +DECISION: an empty `MIN`/`MAX` is `None` on both frontends. R2IL's stack has no +null, so it pushes the fold's identity (i64::MAX / i64::MIN), the same seed +mask-risc's grouped MIN/MAX leave in an empty group; the method layer reads +it as `None`. REVISIT WHEN: R2IL gains a null value. + +OPEN: an ordered literal is in the lane's units, not the canonical unit a +converted claim names; a unit-aware literal needs the claim's conversion. diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index 5b445e0cb..a7624e066 100644 --- a/.claude/board/entries/README.md +++ b/.claude/board/entries/README.md @@ -25,15 +25,17 @@ 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. -317 entries, 2026-08-06 .. 2026-10-12. +319 entries, 2026-08-06 .. 2026-10-12. | date | entry id | finding | file | |---|---|---|---| | 2026-10-12 | `D-SAG-1` | | [2026-10-12-software-awareness-graph.md](2026-10-12-software-awareness-graph.md) | +| 2026-10-12 | `D-CMH-G1-6` | | [2026-10-12-ordered-conditions-and-extrema.md](2026-10-12-ordered-conditions-and-extrema.md) | | 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-G1-4b` | | [2026-10-12-edge-table-anchor-distinct.md](2026-10-12-edge-table-anchor-distinct.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 db1f18d23..b46faf54f 100644 --- a/.claude/plans/gremlin-composable-algebra-v1.md +++ b/.claude/plans/gremlin-composable-algebra-v1.md @@ -114,14 +114,14 @@ Rows group steps of one mechanism, so percentages are of rows, not of step names | step(s) | family | in → out | multiplicity | order/path witness | effect | G1 status | evidence | missing capability | |---|---|---|---|---|---|---|---|---| | V(c) | population | -> vertices of c | bag=rows, bulk 1 | — | pure | DIRECT | anchor = concept table (live plane) | — | -| E() | population | -> edges | bag=edge rows | — | pure | COMPOSE | #1311: edge table as anchor population | relation carrier | +| E() | population | -> edges | bag=edge rows | — | pure | DIRECT (G1-4b) | `V(edge)`: the edge table is a concept, anchored on the edge or child concept (tests/distinct.rs) | — | | hasLabel | selection | V->V | keeps | — | pure | DIRECT | V(c) is the classid population; a second label is another anchor | — | | hasId | selection | V->V | keeps | — | pure | COMPOSE | Cmp::Range / row ordinal | id↔row binding in Glove | | has(f, eq/neq) | selection | V->V | keeps | — | pure | DIRECT | Cmp::EqU32/NeU32 (tests/gremlin.rs) | — | -| has(f, lt/lte/gt/gte) on i32 | selection | V->V | keeps | — | pure | COMPOSE | Cmp::LtI32..GeI32 | Cond ordered compare | +| has(f, lt/lte/gt/gte) on i32 | selection | V->V | keeps | — | pure | DIRECT (G1-6) | `Cond::Order` → Cmp::LtI32..GeI32 / R2IL `and_cmp_i32` (tests/ordered.rs) | — | | has(f, ordered) on u32 | selection | V->V | keeps | — | pure | YET | none: #1311 G5 (`far_side_ordered_predicate_is_refused…`) | ordered u32 compare (G5) | | has(f, within/without) | selection | V->V | keeps | — | pure | COMPOSE | Filter::Or of EqU32 / And of NeU32 | Cond::Or | -| has(f, inside/outside/between) | selection | V->V | keeps | — | pure | COMPOSE | And/Or of i32 compares | Cond ordered compare | +| has(f, inside/outside/between) | selection | V->V | keeps | — | pure | COMPOSE | And/Or of i32 compares | a two-sided `Pred` (`inside`/`between` = two `Cond::Order`; `outside` needs `Cond::Or`) | | 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) | — | @@ -132,20 +132,20 @@ Rows group steps of one mechanism, so percentages are of rows, not of step names | not | boolean | pred->pred | keeps | — | pure | COMPOSE | Filter::Not / dir-sim negate(Semijoin) | Cond::Not | | filter(lambda) | selection | any | — | — | opaque | OUT | — | lambda | | dedup() on anchor | multiplicity | V->V | identity (rows distinct) | — | pure | DIRECT | tests/gremlin.rs dedup().count()==count() | — | -| dedup() after fan-out | multiplicity | bag->set | bag→set | ordered key | pure | COMPOSE | #1311 `bag_and_set_stay_apart…` (ScatterOrU32 / CountDistinctOrderedU32) | relation carrier | +| dedup() after fan-out | multiplicity | bag->set | bag→set | ordered key | pure | DIRECT (G1-4b) | `out(rel).dedup().count()` → `Fold::Distinct` → CountDistinctOrderedU32; an unordered key lane is refused (`LaneNotOrdered`) (tests/distinct.rs) | — (a set as a population, not a count: none) | | dedup() of values | multiplicity | values->set | bag→set | ordered key | pure | YET | refused (DedupOfValues); CountKeyRunsU32 needs ordered lane | ordered first-occurrence mask (FirstOfRun) | | coin/sample/timeLimit | selection | any | random | — | nondet | OUT | — | — | | 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 | 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 | +| in(rel) reverse fk | traversal | V->V' | re-anchor on child rows | — | pure | DIRECT (G1-4b) | spelled `V(child).out(rel)`, anchored on the edge or child concept (tests/distinct.rs); the `V(parent).in(rel)` spelling is still refused (`FanOut`) | composer re-anchoring of the `in` spelling | +| out/in(rel) M:N | traversal | V->V' | re-anchor on edge rows | — | pure | DIRECT (G1-4b) | `V(edge).where(p()).out(end)`: `count()` = bag, `dedup().count()` = set, both ends filtered at the source; anchored on the edge or child concept (tests/distinct.rs) | — | | both(rel) | traversal | V->V' | union of two bags | — | pure | YET | no bag union across anchors | typed bag merge | | second hop, functional | traversal | V->V'' | keeps | — | pure | YET | #1311 `functional_two_hop_is_refused…` | composed fk read (G1) | | second fan-out (fan-in carried) | traversal | V->V'' | bag | — | pure | YET | #1311 `many_to_many_two_hop_is_refused…`; #1312 §D | barrier / fan-in operand (G2) | -| outE/inE/bothE | traversal | V->E | edge rows | — | pure | COMPOSE | edge table as anchor | relation carrier | -| outV/inV/otherV/bothV | traversal | E->V | fk read | — | pure | COMPOSE | EqU32Via from edge row | relation carrier | +| outE/inE/bothE | traversal | V->E | edge rows | — | pure | DIRECT (G1-4b) | `V(edge).where(p())` with `p` over `out(src)`; anchored on the edge or child concept (tests/distinct.rs); `bothE` needs the union below | — | +| outV/inV/otherV/bothV | traversal | E->V | fk read | — | pure | DIRECT (G1-4b) | `out(src)` / `out(dst)` from `V(edge)`: EqU32Via + liveness Semijoin; anchored on the edge or child concept (tests/distinct.rs) | — | | values(f) then fold | projection | V->value | keeps | — | pure | DIRECT | Fold::Sum (tests/gremlin.rs) | — | | values(f) as result | projection | V->values | bag of values | — | pure | YET | Agg::Rows / Keep exist; Answer has no row/value type | row answer type | | valueMap/elementMap/propertyMap/properties | projection | V->map | keeps | — | pure | YET | Agg::Rows | row answer type | @@ -157,7 +157,7 @@ Rows group steps of one mechanism, so percentages are of rows, not of step names | count() of values | aggregation | values->scalar | COUNT(f) | — | pure | YET | refused (CountOfValues) | field-reading count fold | | sum() on anchor i32 | aggregation | values->scalar | bag sum | — | pure | DIRECT | Fold::Sum | — | | sum() through fk | aggregation | values->scalar | bag sum | — | pure | YET | none (#1312 §I gap 4) | masked_sum_i32_via | -| min/max | aggregation | values->scalar | — | — | pure | COMPOSE | Agg::MinI32/MaxI32 | Fold::Min/Max | +| min/max | aggregation | values->scalar | — | — | pure | DIRECT (G1-6) | `Fold::Min/Max` → Agg::MinI32/MaxI32 / R2IL `MIN`/`MAX`; empty → `None` (tests/ordered.rs) | — | | mean | aggregation | values->scalar | — | — | pure | COMPOSE | lower_avg (SUM+COUNT) | a law over two folds | | groupCount().by(f) | aggregation | V->map | bag per key | — | pure | COMPOSE | GroupReduce{Count}; #1311 groupCount tests | Fold::GroupCount | | group().by(f).by(sum) | aggregation | V->map | bag per key | — | pure | DIRECT | Fold::GroupSum (tests/declared.rs) | — | @@ -184,19 +184,19 @@ Rows group steps of one mechanism, so percentages are of rows, not of step names | predicate | status | evidence | |---|---|---| | P.eq / P.neq | DIRECT | Cmp::EqU32/NeU32 | -| P.lt/lte/gt/gte (i32) | COMPOSE | Cmp::*I32; no Cond form | +| P.lt/lte/gt/gte (i32) | DIRECT (G1-6) | `Pred::Lt..Gte` → `Cond::Order` | | P.lt/lte/gt/gte (u32) | YET | G5 | | P.inside/outside/between | COMPOSE | And/Or of compares | | P.within/without | COMPOSE | Or/And of EqU32 | | TextP.startingWith | COMPOSE | PrefixLowering over an ordered dictionary lane | | TextP.endingWith/containing/regex and negations | OUT | strings are codes | -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 +Step rows: 67. DIRECT 20 (30%), COMPOSE 12 (18%), YET 19 (28%), EFFECT 4 (6%), OUT 12 (18%) — after G1-4b; after G1-6: DIRECT 14, COMPOSE 18; after G1-4a: DIRECT 12, COMPOSE 20; at #1508: DIRECT 10, COMPOSE 22 +Predicate rows: 7. DIRECT 2, COMPOSE 3, YET 1, OUT 1 (after G1-6; DIRECT 1, COMPOSE 4 before) 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 ×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 +COMPOSE rows by what the method layer lacks: relation carrier ×0 (×6 before G1-4b, ×8 at #1508); Cond::Or ×3; a two-sided `Pred` ×1 (Cond ordered compare ×2 before G1-6); — ×2; id↔row binding in Glove ×1; value-cursor filter ×1; relation carrier + order witness ×1; Cond::Not ×1; a law over two folds ×1; Fold::GroupCount ×1 ## §5 — What the matrix says to build next @@ -274,6 +274,74 @@ 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. +## §9 — D-CMH-G1-6: ordered `I32` conditions and `MIN`/`MAX` + +- `Cond::Order { field, op, value }` (`<`, `<=`, `>`, `>=` on an `I32` field). + The value is a number in the lane's own units; no codebook resolves it, and + an ordered condition on a coded field is refused (`KindMismatch`). Quack: + `Cmp::{Lt,Le,Gt,Ge}I32`. R2IL: `procs::and_cmp_i32`: `IntSLess` is the + only ordered call, so `>` is `t < lane` and `<=`/`>=` are `IntNot` of `>`/`<`, + confined by the AND with the selection. +- `Fold::Min/Max` over an `I32` field. Quack: `Agg::MinI32/MaxI32`. R2IL: the + `MIN`/`MAX` fold calls, newly wired in `lance-graph-r2il` onto mask-risc's + `MaskedMinI32/MaxI32`. An empty selection answers `FoldValue::Extremum(None)` + on both: Quack returns `OptI32(None)`; R2IL pushes the fold's identity + (`i64::MAX`/`i64::MIN`, the grouped terminals' seed), which no `I32` equals. +- Gremlin: `has(f, lt|lte|gt|gte(n))`, `values(f).min()/max()`, and the same in + text. Refused: `min()/max()` of vertices (`ExtremumOfVertices`), an ordered + `has` after `out(rel)` (`OrderThroughRelation`; no ordered compare reads + through a key), `min/max` on a relation's target (`ValueThroughRelation`). + +No new opcode: `MIN`/`MAX` were already defined in OGAR's fold band and +refused here as unimplemented. + +Evidence: `tests/ordered.rs` (3 tests, CATS glove, both frontends vs a row loop +over the indicator text, n ∈ {1, 64, 65, 4097}, every comparison at thresholds +below, at, between and above the lane's values, including an empty selection); +`lance-graph-r2il/tests/procs.rs` (`min_max_match_the_reference`, +`ordered_compares_match_the_reference`). Disable runs, each red on its test: +Quack `>` as `>=`; R2IL ordered conditions dropped; R2IL identity read as a +value; Quack `None` read as 0; ordered condition bound on a coded field; R2IL +`MIN` lowered to `MAX`; `<=`/`>=` without the negation; empty identity as 0. + +## §10 — D-CMH-G1-4b: an edge table as the anchor, and distinct targets + +An M:N relation is carried as its own concept, with a functional relation to +each end. Each edge row is one traverser, so the one-hop questions need no +new carrier: + +- `V(edge).where(p()).out(end).count()` is the bag (edge rows); +- `V(edge).where(p()).out(end).dedup().count()` is the set: `Fold::Distinct + { rel }`, the number of distinct LIVE targets reached; +- conditions on either end are filtered at the source through the key + (`EqU32Via` + the liveness `Semijoin`), as in G1-4a. No program produces a + mask another program reads. + +`Fold::Distinct` lowers to the relation's liveness test (emitted once, even +when a condition already named the relation) and `COUNT(DISTINCT key)` over +the key lane: `Terminal::CountKeyRunsU32`, two words of state. It needs the +key lane stored in key order, the projection a child or edge table is +stored under; a lane in another order is refused at run time +(`LaneNotOrdered`), never miscounted. `ScatterCountU32` (a population-sized +seen-set) is held and not used. A distinct count over the other end needs +the edges stored in that end's order: a second projection of the concept. + +Composer: `dedup()` after `out(rel)` moves to the distinct targets; `has` +commutes with it; `count()` folds; ending there, or any other step, stays +`DedupThroughRelation`. `V(parent).in(rel)` is still refused (`FanOut`); the +same population is spelled `V(child).out(rel)`. R2IL: `FrontendGap { R2il, +"GATHER" }`. A second hop stays refused: it needs the chained-hop decision +(`.claude/board/entries/2026-10-03-coresearch-chained-hop.md`). + +Evidence: `tests/distinct.rs` (6 tests). FIXTURE: synthetic `method`, +`field` and `reads` tables, dangling keys and dead rows on each, two +projections of `reads` (by field, by method); every answer against a row +loop over the field text at n ∈ {1, 64, 65, 4097}; bag ≠ set on this data. +Seven disable runs, each red on the test that names it: the fold's liveness +test dropped, the test emitted twice, the fold lowered as `COUNT(*)`, the +R2IL refusal dropped, `has` after `dedup` dropped, ending on the distinct +targets accepted, the key left out of `required`. + ## D-ids | D-id | scope | status | @@ -283,5 +351,7 @@ emitted per condition. | 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) | Shipped (#1514) | -| 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-4b | edge table as the anchor (`E()`, M:N, `outE/inV`, `in` spelled from the child), `Fold::Distinct` (§10) | In PR | +| D-CMH-G1-4 | the rest: a second hop (needs the chained-hop decision), the `V(parent).in(rel)` spelling, `bothE` | Queued | | D-CMH-G1-5 | FirstOfRun (insufficiency proof first; separate PR #3) | Queued | +| D-CMH-G1-6 | ordered `I32` conditions, `MIN`/`MAX` folds; R2IL `MIN`/`MAX` wired (§9) | In PR | diff --git a/crates/lance-graph-glove-parity/src/awareness.rs b/crates/lance-graph-glove-parity/src/awareness.rs index 431bc07fd..3189d130a 100644 --- a/crates/lance-graph-glove-parity/src/awareness.rs +++ b/crates/lance-graph-glove-parity/src/awareness.rs @@ -37,8 +37,8 @@ use lance_graph_quack::{lower, Agg, Col, LowerError, Mask, Query}; use crate::basis::{FieldMap, Grade}; use crate::method::{ - Cond, Fold, Law, MethodBody, MethodKey, MethodRef, MethodSpec, MethodTable, PrepareError, - ReadingId, SchemaSeal, + Cond, Fold, Law, MethodBody, MethodKey, MethodRef, MethodSpec, MethodTable, Order, + PrepareError, ReadingId, SchemaSeal, }; /// What a node is. @@ -631,6 +631,22 @@ impl SoftwareGraph { } // A hop's key and target field belong to the glove, not // the spec: the condition is recorded as written. + Cond::Order { + field: name, + op, + value, + } => { + let op = match op { + Order::Lt => "<", + Order::Le => "<=", + Order::Gt => ">", + Order::Ge => ">=", + }; + let f = field(self, name); + let n = self.intern(Kind::Cond, format!("cond:{f}{op}{value}@r{r}")); + self.link(m, Rel::Filters, n, Tier::Declared, m, prov); + continue; + } 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); @@ -659,11 +675,16 @@ impl SoftwareGraph { let ident = match *fold { Fold::Count => "fold:count(*)".to_owned(), Fold::Sum(f) => format!("fold:sum({})", field(self, f)), + Fold::Min(f) => format!("fold:min({})", field(self, f)), + Fold::Max(f) => format!("fold:max({})", field(self, f)), Fold::GroupSum { key, val, groups } => format!( "fold:group_sum({},{},{groups})", field(self, key), field(self, val) ), + // A relation's key and target belong to the glove; the + // fold is recorded as written. + Fold::Distinct { rel } => format!("fold:distinct({rel})"), }; let n = self.intern(Kind::Fold, format!("{ident}@r{r}")); self.link(m, Rel::Folds, n, Tier::Declared, m, prov); diff --git a/crates/lance-graph-glove-parity/src/decl.rs b/crates/lance-graph-glove-parity/src/decl.rs index ac233debe..6c60531b4 100644 --- a/crates/lance-graph-glove-parity/src/decl.rs +++ b/crates/lance-graph-glove-parity/src/decl.rs @@ -32,7 +32,7 @@ use lance_graph_ogar::ogar_vocab::canonical_concept_id; use crate::gremlin::{compose, GremlinRefusal, Pred, Step}; use crate::method::{ Cond, EffectClass, Fold, Glove, Law, MethodBody, MethodKey, MethodRef, MethodSpec, MethodTable, - ReadingId, SchemaSeal, + Order, ReadingId, SchemaSeal, }; /// A method declared in text: a [`MethodSpec`] with owned storage. @@ -58,6 +58,12 @@ enum DeclCond { }, /// A predicate method, by label; resolved at preparation. Ref(String), + /// `has(field, lt|lte|gt|gte(n))`. + Order { + field: String, + op: Order, + value: i32, + }, /// `out(rel)`. Reaches(String), /// `out(rel).has(field, literal)`. @@ -73,11 +79,14 @@ enum DeclCond { enum DeclFold { Count, Sum(String), + Min(String), + Max(String), GroupSum { key: String, val: String, groups: u32, }, + Distinct(String), } impl MethodDecl { @@ -92,6 +101,11 @@ impl MethodDecl { } => Cond::Ne { field, literal }, DeclCond::Field { field, literal, .. } => Cond::Eq { field, literal }, DeclCond::Ref(label) => Cond::Method(MethodRef::Label(label)), + DeclCond::Order { field, op, value } => Cond::Order { + field, + op: *op, + value: *value, + }, DeclCond::Reaches(rel) => Cond::Reaches { rel }, DeclCond::Via { rel, @@ -114,11 +128,14 @@ impl MethodDecl { .map(|f| match f { DeclFold::Count => Fold::Count, DeclFold::Sum(field) => Fold::Sum(field), + DeclFold::Min(field) => Fold::Min(field), + DeclFold::Max(field) => Fold::Max(field), DeclFold::GroupSum { key, val, groups } => Fold::GroupSum { key, val, groups: *groups, }, + DeclFold::Distinct(rel) => Fold::Distinct { rel }, }) .collect() } @@ -606,6 +623,18 @@ fn traversal( }; match pn { "eq" => Pred::Eq(literal(lit).ok_or_else(|| syntax("literal"))?), + "lt" | "lte" | "gt" | "gte" => { + let n: i32 = lit + .trim() + .parse() + .map_err(|_| syntax("an ordered predicate takes an integer"))?; + match pn { + "lt" => Pred::Lt(n), + "lte" => Pred::Lte(n), + "gt" => Pred::Gt(n), + _ => Pred::Gte(n), + } + } "neq" => Pred::Neq(literal(lit).ok_or_else(|| syntax("literal"))?), other => return Err(unsupported(other)), } @@ -641,13 +670,15 @@ fn traversal( _ => Step::Both(r), } } - "dedup" | "count" | "sum" => { + "dedup" | "count" | "sum" | "min" | "max" => { if !args.trim().is_empty() { return Err(syntax(&format!("{name} takes no arguments"))); } match name { "dedup" => Step::Dedup, "count" => Step::Count, + "min" => Step::Min, + "max" => Step::Max, _ => Step::Sum, } } @@ -671,6 +702,11 @@ fn traversal( negate: true, }, Cond::Method(MethodRef::Label(l)) => DeclCond::Ref(l.to_owned()), + Cond::Order { field, op, value } => DeclCond::Order { + field: field.to_owned(), + op, + value, + }, Cond::Reaches { rel } => DeclCond::Reaches(rel.to_owned()), Cond::Via { rel, @@ -692,11 +728,14 @@ fn traversal( .map(|f| match *f { Fold::Count => DeclFold::Count, Fold::Sum(field) => DeclFold::Sum(field.to_owned()), + Fold::Min(field) => DeclFold::Min(field.to_owned()), + Fold::Max(field) => DeclFold::Max(field.to_owned()), Fold::GroupSum { key, val, groups } => DeclFold::GroupSum { key: key.to_owned(), val: val.to_owned(), groups, }, + Fold::Distinct { rel } => DeclFold::Distinct(rel.to_owned()), }) .collect(); Ok((folds, filter)) @@ -706,8 +745,8 @@ fn traversal( fn is_step_name(s: &str) -> bool { EFFECT_STEPS.contains(&s) || [ - "V", "has", "where", "values", "out", "in", "both", "dedup", "count", "sum", "not", - "and", "or", + "V", "has", "where", "values", "out", "in", "both", "dedup", "count", "sum", "min", + "max", "not", "and", "or", ] .contains(&s) } diff --git a/crates/lance-graph-glove-parity/src/gremlin.rs b/crates/lance-graph-glove-parity/src/gremlin.rs index f4d117c02..cdd5266a3 100644 --- a/crates/lance-graph-glove-parity/src/gremlin.rs +++ b/crates/lance-graph-glove-parity/src/gremlin.rs @@ -18,11 +18,12 @@ //! //! | step | on a vertex | on `values(f)` | //! |---|---|---| -//! | `has(f, eq/neq)`, `where(method())` | a condition | refused (`FilterAfterValues`) | +//! | `has(f, eq/neq)`, `has(f, lt/lte/gt/gte(n))`, `where(method())` | a condition | refused (`FilterAfterValues`) | //! | `dedup()` | identity: anchor rows are distinct vertices | refused (`DedupOfValues`: a set of values is another multiset) | //! | `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)` | +//! | `min()` / `max()` | refused (`ExtremumOfVertices`) | `MIN(f)` / `MAX(f)`; `None` when nothing is selected | //! | `out(rel)` | the cursor moves to the target; the anchor stays (below) | — | //! | `in` / `both` | refused (`FanOut`): a fan-out moves the anchor, not carried | — | //! @@ -34,12 +35,19 @@ //! | step | after `out(rel)` | //! |---|---| //! | `has(f, eq/neq)` | a condition on the target's field, read through the key | +//! | `has(f, lt/lte/gt/gte)` | refused (`OrderThroughRelation`): no ordered compare reads through a 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 | +//! | `dedup().count()` | the number of distinct live targets ([`Fold::Distinct`]); the key lane must be stored in key order | +//! | `dedup()` then `has(f, eq/neq)` | as `has` before `dedup`: a target filter commutes with it | +//! | `dedup()` ending, or followed by anything else | refused (`DedupThroughRelation`): the distinct targets are no population of the anchor | //! | `where(method())` | refused (`WhereThroughRelation`): a method of the target concept | //! +//! An M:N relation is an edge concept anchored as its own `V(edge)`, with a +//! functional relation to each end: each edge row is one traverser, so +//! `count()` is the bag and `dedup().count()` the set of reached ends. +//! //! Whether the relation exists, and which target fields it carries, is the //! glove's to say; preparation refuses an unknown one. //! @@ -48,15 +56,37 @@ //! stream this layer has no answer type for (`NoTerminal`). Effectful steps //! (`addV`, `property`, `drop`, …) have no [`Step`] at all: a traversal cannot //! express them, so it cannot carry authority. -use crate::method::{Cond, Fold, Law, MethodBody, MethodRef}; +use crate::method::{Cond, Fold, Law, MethodBody, MethodRef, Order}; -/// A predicate in `has(field, pred)`, over a coded field's literal. +/// A predicate in `has(field, pred)`: equality over a coded field's +/// literal, or an ordered compare of an `I32` field against a number. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Pred<'a> { /// `P.eq(literal)`. Eq(&'a str), /// `P.neq(literal)`. Neq(&'a str), + /// `P.lt(n)`. + Lt(i32), + /// `P.lte(n)`. + Lte(i32), + /// `P.gt(n)`. + Gt(i32), + /// `P.gte(n)`. + Gte(i32), +} + +impl Pred<'_> { + /// The ordered compare this predicate is, if it is one. + fn order(self) -> Option<(Order, i32)> { + match self { + Pred::Lt(n) => Some((Order::Lt, n)), + Pred::Lte(n) => Some((Order::Le, n)), + Pred::Gt(n) => Some((Order::Gt, n)), + Pred::Gte(n) => Some((Order::Ge, n)), + Pred::Eq(_) | Pred::Neq(_) => None, + } + } } /// One traversal step. Names are canonical: a concept id, the concept's @@ -77,6 +107,10 @@ pub enum Step<'a> { Count, /// `sum()`. Sum, + /// `min()`. + Min, + /// `max()`. + Max, /// `out(relation)`. Out(&'a str), /// `in(relation)`. @@ -99,7 +133,7 @@ pub enum GremlinRefusal { SecondHop(String), /// `values(f)` after `out(rel)` (the relation's label). ValueThroughRelation(String), - /// `dedup()` after `out(rel)`. + /// `dedup()` after `out(rel)` not followed by `count()` (or `has`). DedupThroughRelation(String), /// `where(method())` after `out(rel)`. WhereThroughRelation(String), @@ -109,6 +143,11 @@ pub enum GremlinRefusal { CountOfValues, /// `sum()` of vertices. SumOfVertices, + /// `min()` / `max()` of vertices: vertices have no order. + ExtremumOfVertices, + /// An ordered `has` after `out(rel)`: no ordered compare reads through a + /// key. + OrderThroughRelation(String), /// A filter after `values(f)`. FilterAfterValues, /// `values(f)` after `values(g)`. @@ -161,6 +200,9 @@ enum Cursor<'a> { Vertex, /// On the target of `out(rel)`; the anchor has not moved. Reached(&'a str), + /// On the distinct targets of `out(rel)` (`dedup()` after the hop). + /// Only `has` (which commutes with `dedup`) and `count()` follow. + Distinct(&'a str), Value(&'a str), Done, } @@ -198,6 +240,9 @@ pub fn compose<'a>(concept: u16, steps: &[Step<'a>]) -> Result, Gre let (literal, negate) = match pred { Pred::Eq(l) => (l, false), Pred::Neq(l) => (l, true), + Pred::Lt(_) | Pred::Lte(_) | Pred::Gt(_) | Pred::Gte(_) => { + return Err(GremlinRefusal::OrderThroughRelation(rel.to_owned())) + } }; filter.push(Cond::Via { rel, @@ -210,12 +255,41 @@ pub fn compose<'a>(concept: u16, steps: &[Step<'a>]) -> Result, Gre (Step::Where(_), Cursor::Reached(rel)) => { return Err(GremlinRefusal::WhereThroughRelation(rel.to_owned())) } - (Step::Dedup, Cursor::Reached(rel)) => { + (Step::Dedup, Cursor::Reached(rel)) => Cursor::Distinct(rel), + (Step::Has { field, pred }, Cursor::Distinct(rel)) => { + let (Pred::Eq(literal) | Pred::Neq(literal)) = pred else { + return Err(GremlinRefusal::OrderThroughRelation(rel.to_owned())); + }; + filter.push(Cond::Via { + rel, + field, + literal, + negate: matches!(pred, Pred::Neq(_)), + }); + Cursor::Distinct(rel) + } + (Step::Count, Cursor::Distinct(rel)) => { + folds.push(Fold::Distinct { rel }); + Cursor::Done + } + (_, Cursor::Distinct(rel)) => { return Err(GremlinRefusal::DedupThroughRelation(rel.to_owned())) } (Step::Values(_), Cursor::Reached(rel)) => { return Err(GremlinRefusal::ValueThroughRelation(rel.to_owned())) } + (Step::Min | Step::Max, Cursor::Reached(rel)) => { + return Err(GremlinRefusal::ValueThroughRelation(rel.to_owned())) + } + (Step::Min, Cursor::Value(f)) => { + folds.push(Fold::Min(f)); + Cursor::Done + } + (Step::Max, Cursor::Value(f)) => { + folds.push(Fold::Max(f)); + Cursor::Done + } + (Step::Min | Step::Max, _) => return Err(GremlinRefusal::ExtremumOfVertices), (Step::Count, Cursor::Reached(_)) => { folds.push(Fold::Count); Cursor::Done @@ -225,6 +299,12 @@ pub fn compose<'a>(concept: u16, steps: &[Step<'a>]) -> Result, Gre filter.push(match pred { Pred::Eq(literal) => Cond::Eq { field, literal }, Pred::Neq(literal) => Cond::Ne { field, literal }, + Pred::Lt(_) | Pred::Lte(_) | Pred::Gt(_) | Pred::Gte(_) => { + let Some((op, value)) = pred.order() else { + unreachable!("an ordered predicate") + }; + Cond::Order { field, op, value } + } }); Cursor::Vertex } @@ -254,5 +334,9 @@ pub fn compose<'a>(concept: u16, steps: &[Step<'a>]) -> Result, Gre if matches!(cursor, Cursor::Value(_)) { return Err(GremlinRefusal::NoTerminal); } + // The distinct targets are not a population of the anchor. + if let Cursor::Distinct(rel) = cursor { + return Err(GremlinRefusal::DedupThroughRelation(rel.to_owned())); + } Ok(Composed { filter, folds }) } diff --git a/crates/lance-graph-glove-parity/src/method.rs b/crates/lance-graph-glove-parity/src/method.rs index 4456d2ffa..7c8dc4d9d 100644 --- a/crates/lance-graph-glove-parity/src/method.rs +++ b/crates/lance-graph-glove-parity/src/method.rs @@ -126,6 +126,19 @@ pub enum MethodRef<'a> { Label(&'a str), } +/// An ordered comparison of an `I32` field against a number. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Order { + /// `field < value`. + Lt, + /// `field <= value`. + Le, + /// `field > value`. + Gt, + /// `field >= value`. + Ge, +} + /// One condition of a body's `WHERE`, on a canonical field. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Cond<'a> { @@ -133,6 +146,13 @@ pub enum Cond<'a> { Eq { field: &'a str, literal: &'a str }, /// `field != literal`. Ne { field: &'a str, literal: &'a str }, + /// `field op value` on an `I32` field, in the lane's own units. A number, + /// never text: no codebook resolves it. + Order { + field: &'a str, + op: Order, + value: i32, + }, /// The conditions of a predicate method ([`MethodBody::Select`]) of the /// same concept, schema and reading. Preparation replaces the reference /// with those conditions before anything binds, so a call never sees it. @@ -160,7 +180,9 @@ impl<'a> Cond<'a> { /// 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::Eq { field, .. } | Cond::Ne { field, .. } | Cond::Order { field, .. } => { + Some(field) + } Cond::Method(_) | Cond::Reaches { .. } | Cond::Via { .. } => None, } } @@ -173,6 +195,10 @@ pub enum Fold<'a> { Count, /// `SUM(field)` over the selected rows. Sum(&'a str), + /// `MIN(field)` over the selected rows; `None` when none is selected. + Min(&'a str), + /// `MAX(field)` over the selected rows; `None` when none is selected. + Max(&'a str), /// `SUM(val) GROUP BY key` over the selected rows, for the key domain /// `0..groups`. Rows whose key is at or past `groups` are dropped by the /// terminal, not refused. @@ -181,6 +207,13 @@ pub enum Fold<'a> { val: &'a str, groups: u32, }, + /// `out(rel).dedup().count()`: the number of distinct LIVE targets the + /// selected rows reach through `rel`. `COUNT(DISTINCT key)` over the + /// relation's key lane, which must be stored in key order (the + /// projection a child or edge table is stored under): a lane in another + /// order is refused at run time, never miscounted. No target mask is + /// produced; the fold carries two words of state. + Distinct { rel: &'a str }, } /// How the folds' values become the method's result. A fixed set the @@ -226,6 +259,8 @@ pub struct Truth { #[derive(Debug, Clone, PartialEq, Eq)] pub enum FoldValue { Scalar(i64), + /// [`Fold::Min`] / [`Fold::Max`]: `None` when no row was selected. + Extremum(Option), /// One value per group, `0..groups`. Groups(Vec), } @@ -290,8 +325,9 @@ impl<'a> MethodSpec<'a> { .filter_map(|c| c.field()) .chain(folds.iter().flat_map(|f| match *f { Fold::Count => vec![], - Fold::Sum(field) => vec![field], + Fold::Sum(field) | Fold::Min(field) | Fold::Max(field) => vec![field], Fold::GroupSum { key, val, .. } => vec![key, val], + Fold::Distinct { .. } => vec![], })); for f in fields { if !out.contains(&f) { @@ -432,7 +468,18 @@ pub enum Frontend { enum BoundFold { Count, Sum(Col), - GroupSum { key: Col, val: Col, groups: u32 }, + Min(Col), + Max(Col), + GroupSum { + key: Col, + val: Col, + groups: u32, + }, + /// Distinct live targets through the key `fk`. + Distinct { + fk: Col, + plane: ForeignPlane, + }, } /// A bound condition: numbers only. @@ -440,6 +487,8 @@ enum BoundFold { enum BoundCond { /// `lane = code`, or `!=` when `negate`. Local { col: Col, code: u32, negate: bool }, + /// `lane op value` on an `I32` lane. + Ordered { col: Col, op: Order, value: i32 }, /// 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. @@ -522,42 +571,66 @@ impl PreparedMethod { } fn bound_queries(live: Mask, conds: &[BoundCond], folds: &[BoundFold]) -> Vec { - let filter = || { + // A distinct fold tests its relation's liveness, unless the conditions + // already do: the same test is never emitted twice. + let filter = |fold: &BoundFold| { + let extra = match *fold { + BoundFold::Distinct { fk, plane } + if !conds.contains(&BoundCond::Reaches { fk, plane }) => + { + Some(BoundCond::Reaches { fk, plane }) + } + _ => None, + }; 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 + std::iter::once(Filter::plane(live)).chain(conds.iter().chain(&extra).map( + |c| match *c { + BoundCond::Local { col, code, negate } => Filter::cmp( + col, + if negate { + Cmp::NeU32(code) + } else { + Cmp::EqU32(code) + }, + ), + BoundCond::Ordered { col, op, value } => Filter::cmp( + col, + match op { + Order::Lt => Cmp::LtI32(value), + Order::Le => Cmp::LeI32(value), + Order::Gt => Cmp::GtI32(value), + Order::Ge => Cmp::GeI32(value), + }, + ), + 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() .map(|f| Query { - filter: filter(), + filter: filter(f), agg: match *f { BoundFold::Count => Agg::Count, BoundFold::Sum(col) => Agg::SumI32(col), + BoundFold::Min(col) => Agg::MinI32(col), + BoundFold::Max(col) => Agg::MaxI32(col), BoundFold::GroupSum { key, val, .. } => Agg::GroupSumI32 { key, val }, + BoundFold::Distinct { fk, .. } => Agg::CountDistinctOrderedU32 { key: fk }, }, }) .collect() @@ -635,10 +708,38 @@ impl PreparedMethod { .map(|(p, f)| run_quack(p, *f, planes, &reach.foreign)) .collect::, _>>()?, Frontend::R2il => { + // A distinct fold always tests its target's liveness. + if folds + .iter() + .any(|f| matches!(f, BoundFold::Distinct { .. })) + { + return Err(CallError::FrontendGap { + frontend: Frontend::R2il, + missing: "GATHER", + }); + } + let ordered: Vec<(u16, procs::Order, i32)> = conds + .iter() + .filter_map(|c| match *c { + BoundCond::Ordered { col, op, value } => Some(( + col.0, + match op { + Order::Lt => procs::Order::Lt, + Order::Le => procs::Order::Le, + Order::Gt => procs::Order::Gt, + Order::Ge => procs::Order::Ge, + }, + value, + )), + _ => None, + }) + .collect(); let conds = conds .iter() + .filter(|c| !matches!(c, BoundCond::Ordered { .. })) .map(|c| match *c { BoundCond::Local { col, code, negate } => Ok((col.0, code, negate)), + BoundCond::Ordered { .. } => unreachable!("filtered out"), // 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. @@ -652,7 +753,7 @@ impl PreparedMethod { .collect::, _>>()?; folds .iter() - .map(|f| run_r2il(*live, &conds, *f, planes)) + .map(|f| run_r2il(*live, &conds, &ordered, *f, planes)) .collect::, _>>()? } }; @@ -704,6 +805,7 @@ fn run_quack( i64::try_from(n).expect("a row count fits i64"), )), Value::SumI64(s) => Ok(FoldValue::Scalar(s)), + Value::OptI32(v) => Ok(FoldValue::Extremum(v.map(i64::from))), other => panic!("a Count or SumI32 terminal, got {other:?}"), } } @@ -713,31 +815,48 @@ fn run_quack( fn run_r2il( live: Mask, conds: &[(u16, u32, bool)], + ordered: &[(u16, procs::Order, i32)], fold: BoundFold, planes: &Planes<'_>, ) -> Result { + let select = |d: &mut FoldDialect<'_>, stack: &mut Vec| -> Result<(), FoldError> { + procs::select_u32(d, stack, live.0, conds)?; + for &(lane, op, t) in ordered { + procs::and_cmp_i32(d, stack, lane, op, t)?; + } + Ok(()) + }; let mut stack = Vec::new(); if let BoundFold::GroupSum { key, val, groups } = fold { let mut sink = vec![0i64; groups as usize]; let mut d = FoldDialect::with_groups(planes, &Foreign::NONE, &mut sink); - procs::select_u32(&mut d, &mut stack, live.0, conds).map_err(CallError::Fold)?; + select(&mut d, &mut stack).map_err(CallError::Fold)?; procs::group_sum(&mut d, &mut stack, key.0, val.0).map_err(CallError::Fold)?; assert!(stack.is_empty(), "GROUP_SUM pushes nothing, got {stack:?}"); drop(d); return Ok(FoldValue::Groups(sink)); } let mut d = FoldDialect::new(planes, &Foreign::NONE); - procs::select_u32(&mut d, &mut stack, live.0, conds).map_err(CallError::Fold)?; + select(&mut d, &mut stack).map_err(CallError::Fold)?; match fold { BoundFold::Count => procs::count(&mut d, &mut stack), BoundFold::Sum(col) => procs::sum(&mut d, &mut stack, col.0), + BoundFold::Min(col) => procs::min(&mut d, &mut stack, col.0), + BoundFold::Max(col) => procs::max(&mut d, &mut stack, col.0), BoundFold::GroupSum { .. } => unreachable!("handled above"), + BoundFold::Distinct { .. } => unreachable!("refused before the call"), } .map_err(CallError::Fold)?; - match stack.as_slice() { - [Val::Scalar(n)] => Ok(FoldValue::Scalar(*n)), - other => panic!("a fold leaves one scalar, got {other:?}"), - } + let [Val::Scalar(n)] = stack.as_slice() else { + panic!("a fold leaves one scalar, got {stack:?}") + }; + Ok(match fold { + // R2IL answers an empty selection with the fold's identity, which no + // I32 value equals. + BoundFold::Min(_) => FoldValue::Extremum((*n != i64::MAX).then_some(*n)), + BoundFold::Max(_) => FoldValue::Extremum((*n != i64::MIN).then_some(*n)), + _ => FoldValue::Scalar(*n), + }) } /// One concept's methods, bound against one glove. @@ -1060,13 +1179,15 @@ impl Glove<'_> { MethodBody::Query { filter, .. } | MethodBody::Select { filter } => filter, MethodBody::Action { .. } => &[], }; + let folds_of = match spec.body { + MethodBody::Query { folds, .. } => folds, + _ => &[], + }; 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 rel in relations_named(filter, folds_of) { + let fk = self.relation(rel)?.fk; + if !reads.contains(&fk) && !keys.contains(&fk) { + keys.push(fk); } } for field in reads.iter().chain(&keys) { @@ -1101,11 +1222,24 @@ impl Glove<'_> { Fold::Sum(field) => { Ok(BoundFold::Sum(self.lane(t, field, FieldKind::I32)?)) } + Fold::Min(field) => { + Ok(BoundFold::Min(self.lane(t, field, FieldKind::I32)?)) + } + Fold::Max(field) => { + Ok(BoundFold::Max(self.lane(t, field, FieldKind::I32)?)) + } Fold::GroupSum { key, val, groups } => Ok(BoundFold::GroupSum { key: self.lane(t, key, FieldKind::Code)?, val: self.lane(t, val, FieldKind::I32)?, groups, }), + Fold::Distinct { rel } => { + let r = self.relation(rel)?; + Ok(BoundFold::Distinct { + fk: self.lane(t, r.fk, FieldKind::Code)?, + plane: ForeignPlane(r.plane), + }) + } }) .collect::, _>>()?; let live = self.binder.live(t); @@ -1136,17 +1270,15 @@ 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)?; - let (concept, universe) = (r.target.concept, r.target.universe); - match reaches.iter().find(|(c, _)| *c == concept) { - None => reaches.push((concept, universe)), - Some(&(_, u)) if u != universe => { - return Err(PrepareError::ConflictingUniverse(concept)); - } - Some(_) => {} + for rel in relations_named(filter, folds_of) { + let r = self.relation(rel)?; + let (concept, universe) = (r.target.concept, r.target.universe); + match reaches.iter().find(|(c, _)| *c == concept) { + None => reaches.push((concept, universe)), + Some(&(_, u)) if u != universe => { + return Err(PrepareError::ConflictingUniverse(concept)); } + Some(_) => {} } } Ok(PreparedMethod { @@ -1259,6 +1391,13 @@ impl Glove<'_> { Cond::Eq { field, literal } => (field, literal, false), Cond::Ne { field, literal } => (field, literal, true), Cond::Reaches { .. } | Cond::Via { .. } => unreachable!("bound by `conds`"), + Cond::Order { field, op, value } => { + return Ok(BoundCond::Ordered { + col: self.lane(t, field, FieldKind::I32)?, + op, + value, + }) + } // 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)) => { @@ -1331,6 +1470,21 @@ impl Relation<'_> { } } +/// The relations a body names, in order: its conditions', then its folds'. +fn relations_named<'a>(filter: &[Cond<'a>], folds: &[Fold<'a>]) -> Vec<&'a str> { + filter + .iter() + .filter_map(|c| match *c { + Cond::Reaches { rel } | Cond::Via { rel, .. } => Some(rel), + _ => None, + }) + .chain(folds.iter().filter_map(|f| match *f { + Fold::Distinct { rel } => Some(rel), + _ => None, + })) + .collect() +} + /// The body shape a law needs, checked before anything binds. fn check_law(law: Law<'_>, filter: &[Cond<'_>], folds: &[Fold<'_>]) -> Result<(), PrepareError> { match law { diff --git a/crates/lance-graph-glove-parity/tests/distinct.rs b/crates/lance-graph-glove-parity/tests/distinct.rs new file mode 100644 index 000000000..e9c1afefe --- /dev/null +++ b/crates/lance-graph-glove-parity/tests/distinct.rs @@ -0,0 +1,585 @@ +//! D-CMH-G1-4b: an edge table as the anchor, and the distinct targets of a +//! hop. +//! +//! An M:N relation is carried as its own concept: each edge row is one +//! traverser, with a functional relation to each end (`out(by)`, +//! `out(of)`). Both ends are filtered at the source, through the key, so a +//! hop never produces a mask another program reads. +//! +//! - bag: `V(reads)…out(of).count()` counts edge rows (traversers); +//! - set: `V(reads)…out(of).dedup().count()` counts distinct live targets: +//! `COUNT(DISTINCT key)` over a lane stored in key order +//! (`Terminal::CountKeyRunsU32`, two words of state). A lane in another +//! order is refused at run time (`LaneNotOrdered`), never miscounted. A +//! distinct count over the other end needs the edges stored in that end's +//! order: a second projection of the same concept. +//! +//! FIXTURE: synthetic `method`, `field` and `reads` tables, with dangling +//! keys and dead rows on every table. Every answer is checked against a row +//! loop over the TEXT of each field. +use lance_graph_glove_parity::awareness::SoftwareGraph; +use lance_graph_glove_parity::basis::{FieldMap, Grade}; +use lance_graph_glove_parity::decl::{parse_decls, MethodDecl}; +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, Reach, ReadingId, Relation, SchemaSeal, + Universe, +}; +use lance_graph_mask_risc::{words_for, ExecError, Foreign, ForeignPlane, LaneRef, Planes}; +use lance_graph_ogar::ogar_vocab::class_ids::DOCUMENT; +use lance_graph_quack::bind::{Binder, BoundField, FieldKind, TableId}; +use lance_graph_quack::{Agg, Col, Filter, ForeignLane, ForeignPlane as QPlane, Mask, Query}; + +/// FIXTURE concept ids. The edge concept borrows a minted id so its methods +/// can be declared in text, which names concepts through the codebook. +const METHOD: u16 = 0xFFD1; +const FIELD: u16 = 0xFFD2; +const READS: u16 = DOCUMENT; +const READING: ReadingId = ReadingId(1); +const METHOD_U: Universe = Universe(11); +const FIELD_U: Universe = Universe(12); +const READS_U: Universe = Universe(13); +const METHODS: usize = 23; +const FIELDS: usize = 31; +const SIZES: [usize; 4] = [1, 64, 65, 4097]; + +const KINDS: [&str; 3] = ["query", "select", "action"]; +const GRADES: [&str; 3] = ["exact", "converted", "hypothesized"]; + +fn kind(m: usize) -> &'static str { + KINDS[(m * 5 + m / 3) % 3] +} +fn method_live(m: usize) -> bool { + m % 9 != 4 +} +fn grade(f: usize) -> &'static str { + GRADES[(f * 7 + f / 5) % 3] +} +fn field_live(f: usize) -> bool { + f % 8 != 6 +} +fn edge_live(e: usize) -> bool { + e % 11 != 3 +} + +/// Which table order a `reads` projection is stored in. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Order { + ByField, + ByMethod, +} + +/// Edge `e` of `n` as `(method, field)`. The ordering end's key never +/// decreases and runs past the table (dangling keys at the end); the other +/// end is scattered and also dangles. +fn edge(order: Order, n: usize, e: usize) -> (usize, usize) { + match order { + Order::ByField => ((e * 7 + e / 5) % (METHODS + 2), e * (FIELDS + 3) / n), + Order::ByMethod => (e * (METHODS + 2) / n, (e * 13 + e / 4) % (FIELDS + 3)), + } +} + +/// The method an edge reaches: in range and live. +fn by(order: Order, n: usize, e: usize) -> Option { + let m = edge(order, n, e).0; + (m < METHODS && method_live(m)).then_some(m) +} +fn of(order: Order, n: usize, e: usize) -> Option { + let f = edge(order, n, e).1; + (f < FIELDS && field_live(f)).then_some(f) +} + +/// The binder of all three tables. +struct Db; + +impl Binder for Db { + fn table(&self, name: &str) -> Option { + match name { + "reads" => Some(TableId(0)), + "method" => Some(TableId(1)), + "field" => Some(TableId(2)), + _ => None, + } + } + fn live(&self, _: TableId) -> Mask { + Mask(0) + } + fn field(&self, t: TableId, name: &str) -> Option { + let col = match (t.0, name) { + (0, "method") | (1, "kind") | (2, "grade") => 0, + (0, "field") => 1, + _ => return None, + }; + Some(BoundField { + col: Col(col), + kind: FieldKind::Code, + validity: None, + }) + } + fn code(&self, t: TableId, col: Col, literal: &str) -> Option { + let domain: &[&str] = match (t.0, col.0) { + (1, 0) => &KINDS, + (2, 0) => &GRADES, + _ => 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() +} + +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 +} + +/// One size of one projection, owned. +struct Data { + n: usize, + live: Vec, + method: Vec, + field: Vec, + method_live: Vec, + kind: Vec, + field_live: Vec, + grade: Vec, +} + +fn code(d: &[&str], s: &str) -> u32 { + d.iter().position(|x| *x == s).unwrap() as u32 +} + +fn data(order: Order, n: usize) -> Data { + Data { + n, + live: bits(n, edge_live), + method: (0..n).map(|e| edge(order, n, e).0 as u32).collect(), + field: (0..n).map(|e| edge(order, n, e).1 as u32).collect(), + method_live: bits(METHODS, method_live), + kind: (0..METHODS).map(|m| code(&KINDS, kind(m))).collect(), + field_live: bits(FIELDS, field_live), + grade: (0..FIELDS).map(|f| code(&GRADES, grade(f))).collect(), + } +} + +impl Data { + fn run(&self, t: &MethodTable, key: u8, frontend: Frontend) -> Result { + let masks = [self.live.as_slice()]; + let lanes = [LaneRef::U32(&self.method), LaneRef::U32(&self.field)]; + let planes = Planes { + n_rows: self.n, + masks: &masks, + lanes: &lanes, + }; + let fplanes = [ + ForeignPlane { + words: &self.method_live, + rows: METHODS, + }, + ForeignPlane { + words: &self.field_live, + rows: FIELDS, + }, + ]; + let flanes = [LaneRef::U32(&self.kind), LaneRef::U32(&self.grade)]; + let reach = Reach { + foreign: Foreign { + planes: &fplanes, + lanes: &flanes, + }, + universes: &[(METHOD, METHOD_U), (FIELD, FIELD_U)], + }; + t.call_with(MethodKey([key; 16]), &planes, READS_U, &reach, frontend) + } + + fn scalar(&self, t: &MethodTable, key: u8) -> i64 { + match self.run(t, key, Frontend::Quack).unwrap() { + Answer::Values(v) => match v.as_slice() { + [FoldValue::Scalar(c)] => *c, + other => panic!("{other:?}"), + }, + other => panic!("{other:?}"), + } + } +} + +struct Fx { + reads_basis: Vec, + reads_claims: Vec, + method_basis: Vec, + method_claims: Vec, + field_basis: Vec, + field_claims: Vec, +} + +impl Fx { + fn new() -> Self { + Self { + reads_basis: names(&["method", "field"]), + reads_claims: exact(&["method", "field"]), + method_basis: names(&["kind"]), + method_claims: exact(&["kind"]), + field_basis: names(&["grade"]), + field_claims: exact(&["grade"]), + } + } + + fn spec<'b>(&self, key: u8, body: MethodBody<'b>) -> MethodSpec<'b> { + MethodSpec { + key: MethodKey([key; 16]), + concept: READS, + schema: SchemaSeal::of(&self.reads_basis), + reading: READING, + effect: EffectClass::Pure, + body, + } + } + + fn table(&self, specs: &[(&str, MethodSpec<'_>)]) -> MethodTable { + self.table_with(specs, &[]) + } + + fn table_with(&self, specs: &[(&str, MethodSpec<'_>)], decls: &[MethodDecl]) -> MethodTable { + let glove = |concept, basis, claims, table, universe| Glove { + concept, + basis, + claims, + binder: &Db, + table, + reading: READING, + universe, + relations: &[], + }; + let method = glove( + METHOD, + &self.method_basis, + &self.method_claims, + "method", + METHOD_U, + ); + let field = glove( + FIELD, + &self.field_basis, + &self.field_claims, + "field", + FIELD_U, + ); + let rels = [ + Relation { + name: "by", + fk: "method", + target: &method, + plane: 0, + lanes: &[("kind", 0)], + }, + Relation { + name: "of", + fk: "field", + target: &field, + plane: 1, + lanes: &[("grade", 1)], + }, + ]; + let reads = Glove { + relations: &rels, + ..glove( + READS, + &self.reads_basis, + &self.reads_claims, + "reads", + READS_U, + ) + }; + MethodTable::prepare_with(&reads, specs, decls) + } +} + +fn query(filter: &'static [Cond<'static>], folds: &'static [Fold<'static>]) -> MethodBody<'static> { + MethodBody::Query { + filter, + folds, + law: Law::Values, + } +} + +const BY_QUERY: &[Cond] = &[ + Cond::Reaches { rel: "by" }, + Cond::Via { + rel: "by", + field: "kind", + literal: "query", + negate: false, + }, +]; +const FROM_QUERIES: Cond = Cond::Method(MethodRef::Label("by_query")); +const OF: Cond = Cond::Reaches { rel: "of" }; +const OF_EXACT: Cond = Cond::Via { + rel: "of", + field: "grade", + literal: "exact", + negate: false, +}; +const DISTINCT_OF: &[Fold] = &[Fold::Distinct { rel: "of" }]; +const DISTINCT_BY: &[Fold] = &[Fold::Distinct { rel: "by" }]; + +fn specs(fx: &Fx) -> Vec<(&'static str, MethodSpec<'static>)> { + vec![ + ( + "by_query", + fx.spec(1, MethodBody::Select { filter: BY_QUERY }), + ), + // bag: edges from query methods to live fields. + ( + "bag", + fx.spec(2, query(&[FROM_QUERIES, OF], &[Fold::Count])), + ), + // set: distinct live fields read by query methods. + ("set", fx.spec(3, query(&[FROM_QUERIES, OF], DISTINCT_OF))), + // set, with the target filtered at the source. + ("exact", fx.spec(4, query(&[OF_EXACT], DISTINCT_OF))), + // set, with no condition but the hop itself. + ("all", fx.spec(5, query(&[], DISTINCT_OF))), + // set over the other end. + ("methods", fx.spec(6, query(&[OF_EXACT], DISTINCT_BY))), + ] +} + +/// Row-loop oracle: the selected edges of one projection. +fn edges(n: usize, keep: impl Fn(usize) -> bool) -> Vec { + (0..n).filter(|&e| edge_live(e) && keep(e)).collect() +} + +fn distinct(v: impl Iterator) -> i64 { + let mut v: Vec = v.collect(); + v.sort_unstable(); + v.dedup(); + v.len() as i64 +} + +/// FAILS IF: a bag or set count over an edge table, with conditions on +/// either end, disagrees with the row loop, or the two are not different +/// answers on this data. +#[test] +fn bag_and_set_counts_over_an_edge_table_match_the_row_loop() { + let fx = Fx::new(); + let t = fx.table(&specs(&fx)); + let o = Order::ByField; + let mut differ = false; + for n in SIZES { + let d = data(o, n); + let from_query = |e: usize| by(o, n, e).is_some_and(|m| kind(m) == "query"); + let selected = edges(n, |e| from_query(e) && of(o, n, e).is_some()); + let bag = selected.len() as i64; + let set = distinct(selected.iter().map(|&e| of(o, n, e).unwrap())); + assert_eq!(d.scalar(&t, 2), bag, "bag, n={n}"); + assert_eq!(d.scalar(&t, 3), set, "set, n={n}"); + differ |= bag != set; + let exact = edges(n, |e| of(o, n, e).is_some_and(|f| grade(f) == "exact")); + assert_eq!( + d.scalar(&t, 4), + distinct(exact.iter().map(|&e| of(o, n, e).unwrap())), + "exact, n={n}" + ); + let all = edges(n, |e| of(o, n, e).is_some()); + assert_eq!( + d.scalar(&t, 5), + distinct(all.iter().map(|&e| of(o, n, e).unwrap())), + "all, n={n}" + ); + } + assert!(differ, "the bag and the set must differ somewhere"); + // Anti-vacuity at the largest size: a dead or dangling target is not a + // distinct target. + let n = 4097; + let in_range = distinct( + edges(n, |e| edge(o, n, e).1 < FIELDS) + .iter() + .map(|&e| edge(o, n, e).1), + ); + let live = distinct( + edges(n, |e| of(o, n, e).is_some()) + .iter() + .map(|&e| edge(o, n, e).1), + ); + let any = distinct(edges(n, |_| true).iter().map(|&e| edge(o, n, e).1)); + assert!(live < in_range && in_range < any, "{live} {in_range} {any}"); +} + +/// FAILS IF: a distinct count over a lane that is not in key order returns +/// a number. The other end needs a projection stored in its own order. +#[test] +fn a_distinct_count_needs_the_lane_in_key_order() { + let fx = Fx::new(); + let t = fx.table(&specs(&fx)); + let n = 4097; + // Stored by field: distinct methods is refused, not miscounted. + match data(Order::ByField, n).run(&t, 6, Frontend::Quack) { + Err(CallError::Exec(ExecError::LaneNotOrdered { .. })) => {} + other => panic!("{other:?}"), + } + // Stored by method, the same method answers. + let o = Order::ByMethod; + let d = data(o, n); + let exact = edges(n, |e| { + of(o, n, e).is_some_and(|f| grade(f) == "exact") && by(o, n, e).is_some() + }); + assert_eq!( + d.scalar(&t, 6), + distinct(exact.iter().map(|&e| by(o, n, e).unwrap())) + ); + // ...and distinct fields is refused there. + assert!(matches!( + d.run(&t, 5, Frontend::Quack), + Err(CallError::Exec(ExecError::LaneNotOrdered { .. })) + )); +} + +/// FAILS IF: R2IL runs a distinct hop (its fold band has no `GATHER` to +/// test the target's liveness). +#[test] +fn r2il_names_the_missing_gather_for_a_distinct_hop() { + let fx = Fx::new(); + let t = fx.table(&specs(&fx)); + let d = data(Order::ByField, 65); + match d.run(&t, 5, Frontend::R2il) { + Err(CallError::FrontendGap { + frontend: Frontend::R2il, + missing: "GATHER", + }) => {} + other => panic!("{other:?}"), + } +} + +/// FAILS IF: `out(rel).dedup().count()` stops composing to the distinct +/// fold, a target filter around `dedup()` changes the body, or `dedup()` on +/// a target without `count()` composes. +#[test] +fn dedup_after_a_hop_composes_to_the_distinct_fold() { + let v = Step::V(READS); + let c = compose( + READS, + &[ + v, + Step::Where(MethodRef::Label("by_query")), + Step::Out("of"), + Step::Dedup, + Step::Count, + ], + ) + .unwrap(); + assert_eq!(c.filter(), &[FROM_QUERIES, OF]); + assert_eq!(c.folds(), DISTINCT_OF); + // A target filter commutes with dedup. + let has = Step::Has { + field: "grade", + pred: Pred::Eq("exact"), + }; + let before = compose(READS, &[v, Step::Out("of"), has, Step::Dedup, Step::Count]).unwrap(); + let after = compose(READS, &[v, Step::Out("of"), Step::Dedup, has, Step::Count]).unwrap(); + assert_eq!(before, after); + assert_eq!(before.filter(), &[OF, OF_EXACT]); + let refuse = |steps: &[Step<'static>]| compose(READS, steps).unwrap_err(); + for tail in [ + &[][..], + &[Step::Values("grade")][..], + &[Step::Out("x")][..], + &[Step::Where(MethodRef::Label("p"))][..], + &[Step::Dedup][..], + ] { + let mut steps = vec![v, Step::Out("of"), Step::Dedup]; + steps.extend_from_slice(tail); + assert_eq!( + refuse(&steps), + GremlinRefusal::DedupThroughRelation("of".into()), + "{tail:?}" + ); + } +} + +/// FAILS IF: the text form of a distinct hop prepares to other programs +/// than the composed one, or the awareness graph does not record the fold. +#[test] +fn a_distinct_hop_loads_from_text_and_projects() { + let fx = Fx::new(); + let line = format!( + "document::t key={} schema={:016x} reading=1 := \ + V(document).where(by_query()).out(of).dedup().count()\n", + "d7".repeat(16), + SchemaSeal::of(&fx.reads_basis).0 + ); + let decls = parse_decls(&line).unwrap(); + let s = specs(&fx); + let t = fx.table_with(&s, &decls); + let programs = |l: &str| { + t.get(t.resolve(l).unwrap()) + .unwrap() + .as_ref() + .unwrap() + .programs() + .to_vec() + }; + assert_eq!(programs("t"), programs("set")); + let mut g = SoftwareGraph::new(); + g.project(&t, &s, &fx.reads_claims, "distinct.rs", "worktree"); + let set = lance_graph_glove_parity::awareness::method_ident(MethodKey([3; 16])); + assert!( + g.out(&set, lance_graph_glove_parity::awareness::Rel::Folds) + .iter() + .any(|(f, _)| f.starts_with("fold:distinct(of)")), + "{:?}", + g.out(&set, lance_graph_glove_parity::awareness::Rel::Folds) + ); +} + +/// FAILS IF: a distinct hop lowers to anything but the direct Quack query: +/// the selection, the relation's liveness test exactly once (whether or not +/// a condition already named the relation), and `COUNT(DISTINCT key)`. +#[test] +fn a_distinct_hop_lowers_to_the_direct_quack_query() { + let fx = Fx::new(); + let t = fx.table(&specs(&fx)); + let get = |k: u8| t.get(MethodKey([k; 16])).unwrap().as_ref().unwrap(); + let distinct = |filter| { + vec![Query { + filter, + agg: Agg::CountDistinctOrderedU32 { key: Col(1) }, + }] + }; + // The conditions name `of`: its test is not emitted a second time. + assert_eq!( + get(3).queries(), + distinct(Filter::and([ + Filter::plane(Mask(0)), + Filter::semijoin(Col(0), QPlane(0)), + Filter::eq_u32_via(Col(0), ForeignLane(0), 0), + Filter::semijoin(Col(1), QPlane(1)), + ])) + ); + // No condition names `of`: the fold brings its own test. + assert_eq!( + get(5).queries(), + distinct(Filter::and([ + Filter::plane(Mask(0)), + Filter::semijoin(Col(1), QPlane(1)), + ])) + ); + assert!(get(5).required.has(1), "the key is required"); +} diff --git a/crates/lance-graph-glove-parity/tests/gremlin.rs b/crates/lance-graph-glove-parity/tests/gremlin.rs index 6b1a29d9b..09fa10948 100644 --- a/crates/lance-graph-glove-parity/tests/gremlin.rs +++ b/crates/lance-graph-glove-parity/tests/gremlin.rs @@ -375,7 +375,7 @@ fn effects_and_unsupported_steps_are_refused_at_load() { ("path()", "path"), ("select(a)", "select"), ("as(a)", "as"), - ("has(duration, gt(1))", "gt"), + ("has(duration, within(1))", "within"), ("where(not(is_billable()))", "not"), ("mean()", "mean"), ("fold()", "fold"), diff --git a/crates/lance-graph-glove-parity/tests/ordered.rs b/crates/lance-graph-glove-parity/tests/ordered.rs new file mode 100644 index 000000000..e40424527 --- /dev/null +++ b/crates/lance-graph-glove-parity/tests/ordered.rs @@ -0,0 +1,265 @@ +//! Ordered conditions on `I32` fields and the `MIN`/`MAX` folds, on both +//! frontends, against a row loop over the indicator text (the SAP CATS glove +//! of `common`). +//! +//! An ordered literal is compared in the lane's own units: a method states a +//! number, never text, and no codebook resolves it. +mod common; + +use common::*; +use lance_graph_glove_parity::basis::canonical_fields; +use lance_graph_glove_parity::decl::parse_decls; +use lance_graph_glove_parity::gremlin::{compose, GremlinRefusal, Pred, Step}; +use lance_graph_glove_parity::method::{ + Answer, Cond, EffectClass, Fold, FoldValue, Frontend, Law, MethodBody, MethodKey, MethodSpec, + MethodTable, Order, PrepareError, +}; +use lance_graph_ogar::ogar_vocab::class_ids::BILLABLE_WORK_ENTRY; +use lance_graph_sap::binder::CatsBinder; + +const BILLABLE: Cond = Cond::Eq { + field: "billable", + literal: "true", +}; +const FOLDS: &[Fold] = &[ + Fold::Count, + Fold::Sum("duration"), + Fold::Min("duration"), + Fold::Max("duration"), +]; +const ORDERS: [Order; 4] = [Order::Lt, Order::Le, Order::Gt, Order::Ge]; + +fn holds(op: Order, a: i64, t: i64) -> bool { + match op { + Order::Lt => a < t, + Order::Le => a <= t, + Order::Gt => a > t, + Order::Ge => a >= t, + } +} + +fn spec<'a>(key: u8, body: MethodBody<'a>) -> MethodSpec<'a> { + MethodSpec { + key: MethodKey([key; 16]), + concept: BILLABLE_WORK_ENTRY, + schema: seal(), + reading: READING, + effect: EffectClass::Pure, + body, + } +} + +/// `(count, sum, min, max)` of billable rows whose hours satisfy `keep`. +fn oracle(batch: &lance_graph_sap::bind::CatsBatch, keep: impl Fn(i64) -> bool) -> Answer { + let hours: Vec = rows(batch) + .into_iter() + .filter(|&(c, h)| c == 1 && keep(h)) + .map(|(_, h)| h) + .collect(); + Answer::Values(vec![ + FoldValue::Scalar(hours.len() as i64), + FoldValue::Scalar(hours.iter().sum()), + FoldValue::Extremum(hours.iter().copied().min()), + FoldValue::Extremum(hours.iter().copied().max()), + ]) +} + +/// FAILS IF: an ordered condition or a `MIN`/`MAX` fold disagrees with the +/// row loop on either frontend, for each comparison and for thresholds below, +/// at, between and above the lane's values (the last selects nothing, so +/// `MIN`/`MAX` answer `None`). +#[test] +fn ordered_conditions_and_extrema_match_the_row_loop() { + let (basis, claims) = (canonical_fields(), with_duration()); + for n in SIZES { + let batch = batch(n); + let cats = CatsBinder::new(&batch); + let mut vals: Vec = rows(&batch).into_iter().map(|(_, h)| h).collect(); + vals.sort_unstable(); + vals.dedup(); + let (lo, hi) = (vals[0], vals[vals.len() - 1]); + let thresholds = [lo - 1, lo, (lo + hi) / 2, hi, hi + 1]; + let filters: Vec<[Cond<'static>; 2]> = ORDERS + .iter() + .flat_map(|&op| { + thresholds.iter().map(move |&t| { + [ + BILLABLE, + Cond::Order { + field: "duration", + op, + value: i32::try_from(t).unwrap(), + }, + ] + }) + }) + .collect(); + let specs: Vec<(String, MethodSpec<'_>)> = filters + .iter() + .enumerate() + .map(|(i, f)| { + ( + format!("m{i}"), + spec( + i as u8, + MethodBody::Query { + filter: f, + folds: FOLDS, + law: Law::Values, + }, + ), + ) + }) + .collect(); + let labelled: Vec<(&str, MethodSpec<'_>)> = + specs.iter().map(|(l, s)| (l.as_str(), *s)).collect(); + let t = MethodTable::prepare(&glove(&basis, &cats, &claims), &labelled); + let mut empty = false; + for (i, &op) in ORDERS.iter().enumerate() { + for (j, &th) in thresholds.iter().enumerate() { + let key = MethodKey([(i * thresholds.len() + j) as u8; 16]); + let want = oracle(&batch, |h| holds(op, h, th)); + empty |= matches!(&want, Answer::Values(v) if v[2] == FoldValue::Extremum(None)); + for f in [Frontend::Quack, Frontend::R2il] { + assert_eq!( + call(&t, &batch, key, f).unwrap(), + want, + "{op:?} {th}, n={n}, {f:?}" + ); + } + } + } + assert!(empty || n < 4, "some threshold selects nothing (n={n})"); + } +} + +/// FAILS IF: an ordered condition on a coded field, or `MIN`/`MAX` of one, +/// prepares. Order is defined on the `I32` lane, not on a codebook's ids. +#[test] +fn order_on_a_coded_field_is_refused() { + let (basis, claims) = (canonical_fields(), with_duration()); + let batch = batch(4); + let cats = CatsBinder::new(&batch); + let refused = |body| { + let t = MethodTable::prepare(&glove(&basis, &cats, &claims), &[("m", spec(1, body))]); + t.get(MethodKey([1; 16])).unwrap().clone().unwrap_err() + }; + let by_code: &[Cond] = &[Cond::Order { + field: "billable", + op: Order::Gt, + value: 0, + }]; + assert_eq!( + refused(MethodBody::Query { + filter: by_code, + folds: &[Fold::Count], + law: Law::Values, + }), + PrepareError::KindMismatch("billable".into()) + ); + assert_eq!( + refused(MethodBody::Query { + filter: &[], + folds: &[Fold::Min("billable")], + law: Law::Values, + }), + PrepareError::KindMismatch("billable".into()) + ); +} + +/// FAILS IF: `has(f, gt(n))…values(f).max()` stops composing to the same +/// body as the hand-written one, or the text form stops loading to it. +#[test] +fn ordered_steps_compose_and_load() { + let v = Step::V(BILLABLE_WORK_ENTRY); + let steps = [ + v, + Step::Has { + field: "billable", + pred: Pred::Eq("true"), + }, + Step::Has { + field: "duration", + pred: Pred::Gt(100), + }, + Step::Values("duration"), + Step::Max, + ]; + let c = compose(BILLABLE_WORK_ENTRY, &steps).unwrap(); + assert_eq!( + c.filter(), + &[ + BILLABLE, + Cond::Order { + field: "duration", + op: Order::Gt, + value: 100 + } + ] + ); + assert_eq!(c.folds(), &[Fold::Max("duration")]); + for (pred, op) in [ + (Pred::Lt(5), Order::Lt), + (Pred::Lte(5), Order::Le), + (Pred::Gte(5), Order::Ge), + ] { + let c = compose( + BILLABLE_WORK_ENTRY, + &[ + v, + Step::Has { + field: "duration", + pred, + }, + Step::Count, + ], + ) + .unwrap(); + assert_eq!( + c.filter(), + &[Cond::Order { + field: "duration", + op, + value: 5 + }] + ); + } + let refuse = |steps: &[Step<'static>]| compose(BILLABLE_WORK_ENTRY, steps).unwrap_err(); + assert_eq!(refuse(&[v, Step::Min]), GremlinRefusal::ExtremumOfVertices); + assert_eq!(refuse(&[v, Step::Max]), GremlinRefusal::ExtremumOfVertices); + assert_eq!( + refuse(&[ + v, + Step::Out("r"), + Step::Has { + field: "duration", + pred: Pred::Gt(1), + }, + Step::Count, + ]), + GremlinRefusal::OrderThroughRelation("r".into()) + ); + + // Text: the same method, loaded at run time, prepares to the same programs. + let line = format!( + "billable_work_entry::m key={} schema={:016x} reading=1 := \ + V(billable_work_entry).has(billable, \"true\").has(duration, gt(100)).values(duration).max()\n", + "e3".repeat(16), + seal().0 + ); + let decls = parse_decls(&line).unwrap(); + let (basis, claims) = (canonical_fields(), with_duration()); + let batch = batch(65); + let cats = CatsBinder::new(&batch); + let compiled = [("c", spec(0xE4, c.body()))]; + let t = MethodTable::prepare_with(&glove(&basis, &cats, &claims), &compiled, &decls); + let get = |l: &str| { + t.get(t.resolve(l).unwrap()) + .unwrap() + .as_ref() + .unwrap() + .programs() + .to_vec() + }; + assert_eq!(get("m"), get("c")); +} diff --git a/crates/lance-graph-glove-parity/tests/relation.rs b/crates/lance-graph-glove-parity/tests/relation.rs index b9bdf6c3f..065e81e05 100644 --- a/crates/lance-graph-glove-parity/tests/relation.rs +++ b/crates/lance-graph-glove-parity/tests/relation.rs @@ -614,8 +614,11 @@ fn out_composes_and_keeps_the_anchor() { refuse(&[v, out, Step::Values("credit"), Step::Sum]), GremlinRefusal::ValueThroughRelation("billed_to".into()) ); + // `dedup().count()` after the hop is the distinct fold (D-CMH-G1-4b, + // tests/distinct.rs); `dedup()` alone would end on the distinct + // targets, which are no population of the anchor. assert_eq!( - refuse(&[v, out, Step::Dedup, Step::Count]), + refuse(&[v, out, Step::Dedup]), GremlinRefusal::DedupThroughRelation("billed_to".into()) ); assert_eq!( diff --git a/crates/lance-graph-r2il/src/lib.rs b/crates/lance-graph-r2il/src/lib.rs index 944ecc9a3..767d0d554 100644 --- a/crates/lance-graph-r2il/src/lib.rs +++ b/crates/lance-graph-r2il/src/lib.rs @@ -34,10 +34,14 @@ //! //! # Implemented, and refused by name //! -//! Fold band: `VIA`, `SUM`, `GROUP_SUM`, `RANGE` and `KEEP` are wired. -//! `MIN`, `MAX`, `KEY_RUNS`, `ANY`, `ALL`, `SCATTER_OR` and `BLEND` are +//! Fold band: `VIA`, `SUM`, `MIN`, `MAX`, `GROUP_SUM`, `RANGE` and `KEEP` +//! are wired. `KEY_RUNS`, `ANY`, `ALL`, `SCATTER_OR` and `BLEND` are //! refused as [`FoldError::Unimplemented`]. //! +//! `MIN`/`MAX` of an empty selection push the fold's identity, `i64::MAX` / +//! `i64::MIN`: the same seed mask-risc's grouped `MIN`/`MAX` leave in an +//! empty group. No `I32` value equals it, so a caller reads it as SQL NULL. +//! //! R2IL band: `Load` (spaces 0 = U32, 1 = I32, 2 = U64, 3 = resident mask //! plane), `IntSub` (scalar/scalar), `IntAnd` (with survivor gating), //! `IntOr`, `IntNot`, `IntEqual`, `IntSLess` and `PopCount` are wired. `IntLess` and @@ -101,7 +105,7 @@ use lance_graph_mask_risc::{ MaskOp, Operand, Out, Planes, Pred, Program, Scratch, Terminal, Value, }; use ogar_loco::{Dialect, FnIndex}; -use ogar_r2il::{GROUP_SUM, KEEP, R2IL_BASE, RANGE, SUM, VIA}; +use ogar_r2il::{GROUP_SUM, KEEP, MAX, MIN, R2IL_BASE, RANGE, SUM, VIA}; // ── R2IL-band opcodes this dialect wires, by their VERIFIED ordinals ─────── // @@ -910,6 +914,34 @@ impl Dialect for FoldDialect<'_> { stack.push(Val::Scalar(n)); Ok(()) } + MIN | MAX => { + let b = stack.pop().ok_or(FoldError::Underflow(f))?; + let a = stack.pop().ok_or(FoldError::Underflow(f))?; + let i32_lane = |v: Val| match v { + Val::Address(Addr::Lane { + idx, + kind: LaneKind::I32, + }) => Some(idx), + _ => None, + }; + // Either order, as `SUM`. + let (m, lane) = match (fold_mask(a), i32_lane(b), i32_lane(a), fold_mask(b)) { + (Some(m), Some(lane), _, _) | (_, _, Some(lane), Some(m)) => (m, lane), + _ => return Err(FoldError::WrongOperandKind(f)), + }; + let mask = self.resolve_mask(m)?; + let (terminal, empty) = if f == MIN { + (Terminal::MaskedMinI32 { mask, lane }, i64::MAX) + } else { + (Terminal::MaskedMaxI32 { mask, lane }, i64::MIN) + }; + let n = match self.finalize_and_run(terminal, OutKind::None)? { + Value::OptI32(v) => v.map_or(empty, i64::from), + other => panic!("terminal shape guarantees OptI32, got {other:?}"), + }; + stack.push(Val::Scalar(n)); + Ok(()) + } GROUP_SUM => { // Stack spelling: mask, key-address, value-address. The key // ADDRESS picks the physical terminal: a resident U32 lane is diff --git a/crates/lance-graph-r2il/src/procs.rs b/crates/lance-graph-r2il/src/procs.rs index 051039f4e..19d5e1585 100644 --- a/crates/lance-graph-r2il/src/procs.rs +++ b/crates/lance-graph-r2il/src/procs.rs @@ -13,7 +13,7 @@ //! [`Planes::lanes`](lance_graph_mask_risc::Planes::lanes). use ogar_loco::{Dialect, FnIndex}; -use ogar_r2il::{GROUP_SUM, KEEP, SUM}; +use ogar_r2il::{GROUP_SUM, KEEP, MAX, MIN, SUM}; use crate::{ FoldDialect, FoldError, Val, INT_AND, INT_EQUAL, INT_NOT, INT_SUB, INT_S_LESS, LOAD, POP_COUNT, @@ -117,6 +117,70 @@ pub fn sum(d: &mut FoldDialect<'_>, stack: &mut Vec, val_lane: u16) -> Resu call(d, SUM, [0; 3], stack) } +/// `MIN(val_lane | mask)` for the mask on top of the stack. Pushes the +/// minimum, or `i64::MAX` when the mask is empty (SQL NULL; no `I32` value +/// equals it). +/// +/// Calls: `LOAD`, `MIN`. +pub fn min(d: &mut FoldDialect<'_>, stack: &mut Vec, val_lane: u16) -> Result<(), FoldError> { + load(d, stack, SPACE_I32, val_lane)?; + call(d, MIN, [0; 3], stack) +} + +/// `MAX(val_lane | mask)` for the mask on top of the stack. Pushes the +/// maximum, or `i64::MIN` when the mask is empty. +/// +/// Calls: `LOAD`, `MAX`. +pub fn max(d: &mut FoldDialect<'_>, stack: &mut Vec, val_lane: u16) -> Result<(), FoldError> { + load(d, stack, SPACE_I32, val_lane)?; + call(d, MAX, [0; 3], stack) +} + +/// An ordered comparison of an `I32` lane against a threshold. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Order { + /// `lane < t`. + Lt, + /// `lane <= t`. + Le, + /// `lane > t`. + Gt, + /// `lane >= t`. + Ge, +} + +/// AND `lane op t` (an `I32` lane) onto the mask on top of the stack. +/// +/// `IntSLess` is the only ordered compare, so `>` is `t < lane` and `<=` / +/// `>=` are the negations of `>` / `<`. The negation covers every row; the +/// AND with the mask below confines it. +/// +/// Calls: `LOAD`, `IntSLess`, [`IntNot`,] `IntAnd`. +pub fn and_cmp_i32( + d: &mut FoldDialect<'_>, + stack: &mut Vec, + lane: u16, + op: Order, + t: i32, +) -> Result<(), FoldError> { + let t = Val::Scalar(i64::from(t)); + match op { + Order::Lt | Order::Ge => { + load(d, stack, SPACE_I32, lane)?; + stack.push(t); + } + Order::Gt | Order::Le => { + stack.push(t); + load(d, stack, SPACE_I32, lane)?; + } + } + call(d, INT_S_LESS, [0; 3], stack)?; + if matches!(op, Order::Le | Order::Ge) { + call(d, INT_NOT, [0; 3], stack)?; + } + call(d, INT_AND, [0; 3], stack) +} + /// Per-group `SUM(val_lane | mask)` keyed by the `U32` lane `key_lane`, for /// the mask on top of the stack, into the dialect's group sink. Pushes /// nothing. diff --git a/crates/lance-graph-r2il/tests/dialect.rs b/crates/lance-graph-r2il/tests/dialect.rs index 9b05084e4..79a815220 100644 --- a/crates/lance-graph-r2il/tests/dialect.rs +++ b/crates/lance-graph-r2il/tests/dialect.rs @@ -13,8 +13,7 @@ use lance_graph_r2il::{ }; use ogar_loco::{Dialect, FnIndex}; use ogar_r2il::{ - R2ILFn, ALL, ANY, BLEND, GROUP_SUM, KEEP, KEY_RUNS, MAX, MIN, R2IL_BASE, RANGE, SCATTER_OR, - SUM, VIA, + R2ILFn, ALL, ANY, BLEND, GROUP_SUM, KEEP, KEY_RUNS, R2IL_BASE, RANGE, SCATTER_OR, SUM, VIA, }; /// The R2IL-band ordinal of `f`, as an index into `R2ILFn::MNEMONICS`. @@ -84,7 +83,7 @@ fn every_named_refusal_is_still_produced() { // wired since D-SQLP-2. let int_add = FnIndex(R2IL_BASE + 9); assert_eq!(mnemonic(int_add), "IntAdd"); - for f in [MIN, MAX, KEY_RUNS, ANY, ALL, SCATTER_OR, BLEND, int_add] { + for f in [KEY_RUNS, ANY, ALL, SCATTER_OR, BLEND, int_add] { let mut d = FoldDialect::new(&planes, &foreign); let mut stack = vec![Val::Scalar(1), Val::Scalar(2)]; assert_eq!( diff --git a/crates/lance-graph-r2il/tests/procs.rs b/crates/lance-graph-r2il/tests/procs.rs index 366ee1636..f88038002 100644 --- a/crates/lance-graph-r2il/tests/procs.rs +++ b/crates/lance-graph-r2il/tests/procs.rs @@ -5,7 +5,9 @@ //! selection, a partial one and a full one. use lance_graph_mask_risc::{words_for, Foreign, LaneRef, MaskOp, Planes}; -use lance_graph_r2il::procs::{count, filter_lt, group_sum, margin, select_lt, select_u32, sum}; +use lance_graph_r2il::procs::{ + and_cmp_i32, count, filter_lt, group_sum, margin, max, min, select_lt, select_u32, sum, Order, +}; use lance_graph_r2il::{ BoolMode, FoldDialect, FoldError, Val, INT_AND, INT_NOT, INT_OR, POP_COUNT, }; @@ -426,3 +428,74 @@ fn every_fold_takes_a_resident_plane_as_its_mask() { // Non-vacuity: the plane drops rows, so reading every row would differ. assert!(kept.len() < N); } + +/// FAILS IF: `min`/`max` stop matching `MIN`/`MAX(revenue | x < t)`, or an +/// empty selection stops answering the fold's identity (`i64::MAX` for +/// `MIN`, `i64::MIN` for `MAX`): the grouped terminals' seed, which no `I32` +/// value can equal, so a caller reads it as SQL NULL. +#[test] +fn min_max_match_the_reference() { + let f = fixture(); + let lanes = lanes(&f); + let planes = Planes { + n_rows: N, + masks: &[], + lanes: &lanes, + }; + // `(t, value lane)`: the thresholds, plus one where the mask decides + // the answer (rows with `x < 1` all have cost 0; the table's max is 4). + let cases = THRESHOLDS + .iter() + .map(|&t| (t, REVENUE, &f.revenue)) + .chain([(1, COST, &f.cost)]); + for (t, lane, vals) in cases { + let sel = || (0..N).filter(|&r| f.x[r] < t).map(|r| i64::from(vals[r])); + let lo = sel().min().unwrap_or(i64::MAX); + let hi = sel().max().unwrap_or(i64::MIN); + for (fold, want) in [(min as Fold, lo), (max as Fold, hi)] { + let mut d = FoldDialect::new(&planes, &Foreign::NONE); + let mut stack = vec![]; + select_lt(&mut d, &mut stack, X, t).expect("select"); + fold(&mut d, &mut stack, lane).expect("fold"); + assert_eq!(stack, vec![Val::Scalar(want)], "t = {t}, lane {lane}"); + } + } + let masked_max = (0..N).filter(|&r| f.x[r] < 1).map(|r| f.cost[r]).max(); + assert_eq!(masked_max, Some(0)); + assert_eq!(f.cost.iter().copied().max(), Some(4)); +} + +/// A row-level reference for an ordered compare. +type Holds = fn(i32, i32) -> bool; + +type Fold = fn(&mut FoldDialect<'_>, &mut Vec, u16) -> Result<(), FoldError>; + +/// FAILS IF: an ordered compare ANDed onto a selection stops matching +/// `y op t` row by row, for each of `<`, `<=`, `>`, `>=`. +#[test] +fn ordered_compares_match_the_reference() { + let f = fixture(); + let lanes = lanes(&f); + let planes = Planes { + n_rows: N, + masks: &[], + lanes: &lanes, + }; + let ops: [(Order, Holds); 4] = [ + (Order::Lt, |a, b| a < b), + (Order::Le, |a, b| a <= b), + (Order::Gt, |a, b| a > b), + (Order::Ge, |a, b| a >= b), + ]; + for (op, holds) in ops { + for t in [-1, 0, 15, 30, 31] { + let want = (0..N).filter(|&r| f.x[r] < 40 && holds(f.y[r], t)).count() as i64; + let mut d = FoldDialect::new(&planes, &Foreign::NONE); + let mut stack = vec![]; + select_lt(&mut d, &mut stack, X, 40).expect("select"); + and_cmp_i32(&mut d, &mut stack, Y, op, t).expect("compare"); + count(&mut d, &mut stack).expect("count"); + assert_eq!(stack, vec![Val::Scalar(want)], "{op:?} {t}"); + } + } +}