diff --git a/.claude/board/INTEGRATION_PLANS.md b/.claude/board/INTEGRATION_PLANS.md index bde53bfba..46978a4a7 100644 --- a/.claude/board/INTEGRATION_PLANS.md +++ b/.claude/board/INTEGRATION_PLANS.md @@ -1,3 +1,12 @@ +## 2026-10-12 (3) — gremlin-composable-algebra-v1 — Gremlin steps as typed building blocks of ClassId methods → `.claude/plans/gremlin-composable-algebra-v1.md` + +**Status:** MEASURED for one population (D-CMH-G1-0..3 shipped in the PR); D-CMH-G1-4..5 queued. + +- A method names another: predicate methods (`MethodBody::Select`) referenced by `Cond::Method`, inlined at preparation; no call at run time. +- Typed Gremlin steps (`V/has/where/values/dedup/count/sum`) compose into a method body under #1311's anchor rule; hops refused until the glove declares a relation carrier. +- Gremlin-shaped declarations load as text at run time; effects are refused at load. +- Vocabulary matrix over TinkerPop 3.7.7: next lever is a relation carrier in the glove (a binding), then the ordered-key family. + ## 2026-10-12 (2) — nal-polyfill-v1 — `NAL::narsese()` as a polyfill over R2IL procedures and mask-risc → `.claude/plans/nal-polyfill-v1.md` **Status:** PROPOSAL (D-NALP-0..11). Only D-NALP-0 (this plan); each wave is gated on the one before. diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index 57b64ae80..cd1de6e3f 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -1,3 +1,16 @@ +## D-CMH-G1 — Gremlin as a composable ClassId algebra (2026-10-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-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) Plan: `.claude/plans/nal-polyfill-v1.md`. Template: D-SQLP. No opcode, tenant or ABI change is authorized. diff --git a/.claude/board/entries/2026-10-12-class-method-polyfill-ledger.md b/.claude/board/entries/2026-10-12-class-method-polyfill-ledger.md index 2a74b224e..586a95a50 100644 --- a/.claude/board/entries/2026-10-12-class-method-polyfill-ledger.md +++ b/.claude/board/entries/2026-10-12-class-method-polyfill-ledger.md @@ -56,7 +56,8 @@ them and cannot supply its own. | Reading and population ids | No ClassView reading id and no plane generation id exist. Both are fixtures here. | — | Issue from the ClassView and the plane owner. | — | | Compute bodies (fields derived from fields) | Not attempted. The compute-DAG check now reads all 256 positions (it was fail-open past 63: `10→70→10` read acyclic). | `compute_dag_cycle_crossing_63_64_is_rejected` and four siblings, red on the 64-bit code. | A compute body may now land; the DAG gate no longer limits it. | Hand-checked orders; `assert_topological`. | | Runtime declarations | Done (step 2): `decl::parse_decls` + `MethodTable::prepare_with`; one build loads any declarations file. Grammar: `COUNT(*)`, `SUM`, `GROUP_SUM … BY … IN`, `WHERE = / != … AND …`; everything else refused by name. | — | — | Row loop over indicator text (`tests/declared.rs`); the compiled twin's programs. | -| **Next: compositional formulas** — a declared method reused by another, e.g. `is_billable := billable = true`, `billable_hours := SUM(duration WHERE is_billable())`, `billable_count := COUNT(WHERE is_billable())`. | No method reference in `MethodBody`; a predicate method has no body shape (it returns a selection, not folds). Today `… WHERE is_billable()` is refused as `method reference`. | `unsupported_formulas_are_refused_by_name`. | A predicate body (`Select{filter}`) and a `Cond::Method(key)` resolved at preparation, inlined into the caller's conjunction, cycles refused; no new Rust per method. Tracked as D-CMH-G1. | The inlined method's programs equal the hand-written conjunction's. | +| Compositional formulas | Done (D-CMH-G1): `MethodBody::Select` + `Cond::Method`, inlined at preparation; typed Gremlin steps compose into bodies; Gremlin-shaped text loads at run time. `billable_hours` names the separately declared `is_billable`, no Rust for either. | `…WHERE is_billable()` inside an aggregate is still refused (the SQL-ish grammar); the Gremlin form carries references. | — | Programs equal the hand conjunction's / direct Quack's; row loop on both frontends. | +| **Next: relation carrier in the glove** | A method glove declares no relation, so every `out/in/both` is refused (`NoRelationCarrier`) though #1311 lowers one hop of each kind. | `gremlin.rs::steps_outside_the_anchor_contract_are_refused`. | `(carrier fk | edge, fields, direction, target)` per relation; move #1311's anchor rule into `compose`. A binding, not a primitive; 9 matrix rows. | #1311's bulk oracle and DuckDB cases. | | Fused folds | One mask-risc program per fold; the selection is re-emitted per fold. | — | A multi-sink terminal; an optimisation only, no semantic change. | Same answers. | No new mask-risc opcode or R2IL op was added. One R2IL change: `POP_COUNT` @@ -143,3 +144,8 @@ resident), and fused-vs-unfused lowering (no fused terminal exists). frontends: 6; refused by design: 2. New algebra members: 0 since the freeze; executor fixes: 1 (R2IL plane masks, found by B). New laws: 1. New ISA: 0. Domain Rust: 0. + +**Metrics after D-CMH-G1.** Methods declared in tests or text: +14 (predicates, consumers, +refusal fixtures); domain Rust functions: 0; new physical instructions: 0; new +Quack/R2IL variants: 0. Gremlin vocabulary (TinkerPop 3.7.7, per row of +`gremlin-composable-algebra-v1.md` §4): DIRECT 15%, COMPOSE 33%, YET 28%, EFFECT 6%, OUT 18%. diff --git a/.claude/plans/gremlin-composable-algebra-v1.md b/.claude/plans/gremlin-composable-algebra-v1.md new file mode 100644 index 000000000..02894e09a --- /dev/null +++ b/.claude/plans/gremlin-composable-algebra-v1.md @@ -0,0 +1,245 @@ +# gremlin-composable-algebra-v1 — Gremlin steps as typed building blocks of ClassId methods (D-CMH-G1) + +> **Status:** MEASURED for the slice in §2 (code: `crates/lance-graph-glove-parity/src/{method,gremlin,decl}.rs`, +> tests `tests/{compose,gremlin}.rs`). The vocabulary matrix (§4) is ANALYSIS against code and tests, +> one row per step family. Stacked on #1507 (runtime declarations); independent of #1506. +> **Ledger:** `.claude/board/entries/2026-10-12-class-method-polyfill-ledger.md`. + +## §0 — Question and answer + +Can Gremlin's vocabulary be a set of typed building blocks from which ClassId methods +are composed, with no second interpreter and no new instruction? **For one population, +yes.** A method can name another method; Gremlin steps compose into a method body; the +body goes through the same preparation and Quack lowering as every method (#1504); and +it can be loaded as text into a binary that was compiled before the text existed. + +The result to look at: `billable_hours` calls `is_billable`, a separately declared +predicate method. Both are text in `tests/fixtures/lego.methods`, loaded at run time. +There is no Rust for either method. `billable_hours` lowers to exactly the `Program` +of the direct Quack query (`SUM(hours) WHERE billable = true`), and runs on both frontends. + +## §1 — What already shipped (not this PR) + +| piece | where | what it gives this PR | +|---|---|---| +| #1311 Gremlin↔SQL witness | `crates/lance-graph-quack/tests/gremlin_parity.rs` (test-only, 13 tests, re-run green on this tree) | the anchor rule: one traverser = one anchor row; functional hop keeps the anchor, one fan-out re-anchors on the path rows; bag and set kept apart | +| #1312 corrections | `.claude/plans/population-law-crosscheck-v1.md` | a second fan-out is not a barrier by hop count; the barrier is a fan-in aggregate carried forward | +| #1504 ClassId methods | `src/{method,call}.rs` | `MethodSpec`, `MethodTable::prepare` (seal, reading, claims, laws, Quack lowering once), two frontends | +| #1507 runtime declarations | `src/decl.rs` | owned declarations lent to `prepare` | +| #1506 compute DAG over 256 positions | contract | prerequisite for derived fields; this PR adds none | + +## §2 — What this PR adds + +1. **Predicate methods and method references.** `MethodBody::Select { filter }` folds nothing. + `Cond::Method(MethodRef::{Key | Label})` names one. `MethodTable::prepare` resolves each + reference once and inlines the predicate's conditions into the caller's conjunction + before binding, so no call exists at run time. +2. **Result types stay apart.** Only a predicate can be named: a reference to a fold + (scalar) or an action (effect) is `MethodRefNotPredicate`; a pure call of a predicate is + `CallError::NotTerminal`; the referenced predicate must share concept, schema, reading + and effect with the caller (`MethodRefContract`), so a same-shaped predicate of another + class is not captured. +3. **Typed Gremlin steps.** `gremlin::compose(concept, &[Step])` turns + `V / has(eq,neq) / where(method()) / values / dedup / count / sum` into a body. The + anchor contract decides what composes (module doc of `gremlin.rs`); hops are refused + because the method glove declares no relation carrier; effects have no `Step`. +4. **Gremlin-shaped text.** `V(concept).has(f, true).where(m()).values(f).sum()` loads + through `decl.rs` into owned storage. Effect steps are `DeclError::Effect`, other steps + and predicates are `Unsupported` by name; every loaded declaration is pure. + +No new mask-risc op, no new R2IL op, no new Quack variant, no method-specific Rust. + +## §3 — Evidence + +**Acceptance (the four properties):** + +| property | test | +|---|---| +| a method references an independently declared predicate | `compose.rs::two_methods_reuse_one_predicate_method`; text: `gremlin.rs::the_lego_methods_load_from_text_and_match_the_compiled_ones` | +| both are declarations, no domain Rust | `tests/fixtures/lego.methods`; `gremlin.rs::a_later_declaration_reuses_an_earlier_predicate` (written after the first load) | +| same Quack program as the direct expression | `compose.rs` (`programs()` == hand conjunction); `gremlin.rs::a_composed_traversal_is_the_direct_quack_query` (== `lower(Query{table(..).where_eq(..)…, SumI32})`) | +| rejects cycles, incompatible populations, unsupported multiplicity, cross-class capture | `compose.rs::a_broken_reference_is_refused` (self, two-step and transitive cycles; seal; reading; fold/action targets; unknown/ambiguous), `…another_class_is_not_captured`, `gremlin.rs::steps_outside_the_anchor_contract_are_refused` | + +Each answer is checked on Quack and R2IL against a row loop over the indicator text, at +n ∈ {1, 64, 65, 4097}. Editing only `is_billable` moves both consumers +(`editing_the_predicate_moves_every_consumer`), so the reuse is by reference, not by copy. + +**Mutations (each a typed refusal or a red oracle test):** + +| corrupted | caught by | +|---|---| +| relation direction | method layer: every hop refused (`NoRelationCarrier`); substrate: #1311 `reverse_fanout_reanchors…` and its disable run (an edge hop that drops the target-exists semijoin) | +| anchor population | `V(document)` in a `billable_work_entry` method → `AnchorMismatch` | +| traverser multiplicity | `values().dedup()` → `DedupOfValues`; `values().count()` → `CountOfValues`; `dedup().count()` = `count()` only on the anchor, asserted; across a fan-out, #1311 `bag_and_set_stay_apart_after_a_fanout` | +| field identity | `UnknownField` through a referenced predicate; `polyfill.rs` swapped-lane binder (both frontends disagree with the oracle) | +| SchemaSeal / ReadingId | referenced predicate of another seal or reading → `MethodRefContract` | +| source generation | `polyfill.rs::planes_of_another_population_are_refused` (`UniverseMismatch`) | +| method reference | unknown key/label, ambiguous label, cycle, fold/action target | +| dedup precondition | `DedupOfValues` | +| effect classification | 13 effect steps → `DeclError::Effect`; `MethodDecl::effect()` is always `Pure`; an action cannot be named as a predicate | + +**Disable runs (each red, then restored):** inlining skipped (4/5 of `compose.rs` red); +the contract check compared the target with itself (refusal test red); the concept +dropped from the contract (cross-class test red); the effect gate removed (effects test +red); an ambiguous key resolved to its first hit (refusal test red). + +**Cost** (`examples/compose_cost.rs`, 1,048,576 rows, 101 reps, shared machine; tier printed +from compiled target features, not inferred from flags): + +| path | native (`avx512f=true`) median / min | portable v3 (`avx512f=false avx2=true`) median / min | +|---|---|---| +| cold: parse 3 declarations | 4.9 / 3.7 µs | 3.1 / 2.9 µs | +| cold: `prepare_with` (resolve, inline, bind, lower) | 2.5 / 2.0 µs | 1.7 / 1.5 µs | +| warm: composed `billable_hours`, Quack | 755 / 667 µs | 662 / 476 µs | +| warm: composed, R2IL | 762 / 677 µs | 663 / 472 µs | +| warm: the same `Program` run directly | 749 / 676 µs | 660 / 464 µs | + +A composed call runs the direct query's `Program`, so its warm cost equals direct execution +within noise. The native build is not faster than v3 here, so no SIMD gain is claimed +from the flag. The cold cost is microseconds per file. + +## §4 — Vocabulary → algebra matrix (TinkerPop 3.7.7) + +Reference: https://tinkerpop.apache.org/docs/3.7.7/reference/ (latest 3.7.x; read 2026-10-12). +Rows group steps of one mechanism, so percentages are of rows, not of step names. + +- **DIRECT** = lowers through the G1 method layer today, tested here. +- **COMPOSE** = existing Quack/mask-risc primitives give it (tested in #1311 or the Quack + suites), but the method layer has no surface for it yet. +- **YET** = needs reusable algebra that does not exist; the evidence column names the + refusal or counterexample. +- **EFFECT** = state or authority. +- **OUT** = outside the execution contract (lists, strings, lambdas, randomness, OLAP). + +| 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 | +| 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, 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 | +| 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) 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 | +| 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() 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 | COMPOSE | #1311 functional hop: Semijoin + EqU32Via | relation carrier | +| 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 | +| 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 | +| 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 | +| id/label/key/value/element | projection | elem->scalar | keeps | — | pure | YET | Agg::Rows | row answer type | +| constant/identity/barrier | projection | x->x | keeps | — | pure | COMPOSE | no-op in a bulk executor | — | +| as/select/project/by | binding | traverser->map | keeps | labels | pure | YET | none | binding environment | +| match | binding | pattern | bag | labels | pure | YET | none | binding environment + join | +| count() | aggregation | V->scalar | bag count | — | pure | DIRECT | Fold::Count (tests/gremlin.rs, tests/compose.rs) | — | +| 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 | +| 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) | — | +| fold/unfold | aggregation | bag<->list | — | — | pure | OUT | no list type | list values | +| math | arithmetic | scalars->scalar | — | — | pure | OUT | string expression | — | +| union (same population) | set | pred,pred->pred | set union of rows | — | pure | COMPOSE | Filter::Or | Cond::Or | +| union (different traversals) | set | bag,bag->bag | bag sum | — | pure | YET | none | typed bag merge | +| intersect/difference/disjunct/combine/merge/product/conjoin/reverse/length (lists) | list | list->list | — | — | pure | OUT | no list type | list values | +| choose/coalesce/optional/branch/local | branching | traverser->traverser | per-branch | — | pure | YET | none | typed branch + merge | +| repeat/until/emit/times/loops | iteration | V->V* | bag or set | path | pure | YET | #1306 frontier fixpoint (plan only) | bounded fixpoint | +| path/simplePath/cyclicPath/tree | path | traverser->path | per path | path | pure | YET | none | provenance-carrying path | +| shortestPath | path | V->path | — | path | pure | OUT | — | — | +| order().by() | ordered | bag->sequence | keeps | order | pure | YET | none (row order only) | ordered population op | +| addV/addE/property/drop | mutation | — | — | — | effect | EFFECT | refused at load (tests/gremlin.rs) | dir-sim delta / action gate | +| mergeV/mergeE | mutation | — | — | — | effect | EFFECT | refused at load | dir-sim delta / action gate | +| sideEffect/aggregate/store/sack/cap/subgraph | state | — | — | — | state | EFFECT | refused at load (cap/subgraph: not parsed) | explicit computation state | +| inject/io/call | source | — | — | — | external | EFFECT | refused at load | authority boundary | +| fail | control | — | — | — | effect | OUT | — | — | +| concat/asString/toUpper/toLower/trim/lTrim/rTrim/split/substring/replace/format | string | string->string | — | — | pure | OUT | strings are dictionary codes | — | +| asDate/dateAdd/dateDiff | date | date->date | — | — | pure | OUT | — | — | +| pageRank/peerPressure/connectedComponent/program | OLAP | graph->graph | — | — | pure | OUT | — | — | +| map/flatMap/sideEffect (lambda), index, explain, profile | meta | — | — | — | — | OUT | — | — | + +| 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 (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 10 (15%), COMPOSE 22 (33%), YET 19 (28%), EFFECT 4 (6%), OUT 12 (18%) +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 + +## §5 — What the matrix says to build next + +- **Most leverage, and no new algebra: a relation carrier in the `Glove`** — `(carrier: fk | + edge table, field(s), direction, target concept)`, the four facts #1311's adapter needed + per relation. It turns 9 COMPOSE rows (functional `out`, reverse `in`, one M:N hop, `E`, + `outE/inE`, `outV/inV`, `where` over a hop, `dedup` after a fan-out) into method-layer + steps by moving #1311's anchor rule into `compose`. It is a binding, not a primitive. +- **Small method-layer surfaces over existing primitives:** `Cond::Or/Not`, ordered `Cond` + over `i32`, `Fold::Min/Max/GroupCount`, a mean law (9 COMPOSE rows). +- **Reusable algebra (YET), by cross-domain reuse:** + 1. ordered-key family — first-occurrence mask (FirstOfRun; NARS stamps, IAM, SAP/Odoo dedup), + ordered population ops (`order/range/limit`), ordered `u32` compare (G5); + 2. a row/value answer type (`values` as a result, `valueMap`, `id`) — `Agg::Rows` exists, `Answer` has no row type; + 3. composed fk read (G1) and the fan-in operand (G2), the two traversal gaps #1311/#1312 named; + 4. binding environments (`as/select/project/match`), typed branch and merge, bounded fixpoint, paths. + +None is built here. Each needs the ledger's extension criteria (a method that needs it, a +counterexample, an independent oracle, both frontends, a second domain). + +## §6 — Effects and IAM + +No Gremlin effect step loads: `addV/addE/property/drop/mergeV/mergeE` and the state steps +are refused at parse time, and `MethodDecl::effect()` is `Pure` for every declaration. An +administrative intent such as `IAM::group(g)::add(member)` belongs to the action gate and +dir-sim's version/delta machinery as a proposal or simulated transition; loading a +traversal grants no authority. Not exercised here: no write of any kind. + +## §7 — Falsifiers + +1. A composed method whose `programs()` differ from its direct conjunction. +2. A reference that resolves across concepts, seals or readings. +3. A traversal accepted by `compose` whose answer differs from the row oracle. +4. `dedup` or `count` composed past `values(f)` without a refusal. +5. A declaration that loads an effect. + +## 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-5 | FirstOfRun (insufficiency proof first; separate PR #3) | Queued | diff --git a/crates/lance-graph-glove-parity/examples/compose_cost.rs b/crates/lance-graph-glove-parity/examples/compose_cost.rs new file mode 100644 index 000000000..828774b24 --- /dev/null +++ b/crates/lance-graph-glove-parity/examples/compose_cost.rs @@ -0,0 +1,166 @@ +//! D-CMH-G1 cost: what loading a composed method from text costs, cold, and +//! what calling it costs, warm, against the same query run directly. +//! +//! Every timed path is asserted to give the same answer before it is timed. +//! Run: `cargo run --release --example compose_cost -- [rows] [reps]` +use std::hint::black_box; +use std::time::Instant; + +use lance_graph_glove_parity::basis::{canonical_fields, FieldMap, Grade, SAP_FIELDS}; +use lance_graph_glove_parity::decl::parse_decls; +use lance_graph_glove_parity::method::{ + Answer, FoldValue, Frontend, Glove, MethodTable, ReadingId, Universe, +}; +use lance_graph_mask_risc::{execute_into, Foreign, LaneRef, Out, Planes, Scratch, Value}; +use lance_graph_ogar::ogar_vocab::class_ids::BILLABLE_WORK_ENTRY; +use lance_graph_sap::bind::{CatsBatch, BILLABLE, BILLING, HOURS}; +use lance_graph_sap::binder::{live_words, CatsBinder, TABLE}; +use lance_graph_sap::schema::{CatsSchema, FIELD_COUNT}; + +const INDICATORS: [&str; 4] = ["Billable", "Non-Billable", "Internal Cost", "Unknown"]; +const HOURS_TEXT: [&str; 3] = ["8.50", "0.125", "2"]; + +fn batch(n: usize) -> CatsBatch { + let base: Vec> = include_str!("../../lance-graph-sap/fixtures/cats.txt") + .lines() + .map(|v| if v == "\\N" { None } else { Some(v) }) + .collect(); + let mut input: [Vec>; FIELD_COUNT] = std::array::from_fn(|i| vec![base[i]; n]); + for i in 0..n { + input[BILLING][i] = Some(INDICATORS[(i * 7 + i / 3) % 4]); + input[HOURS][i] = Some(HOURS_TEXT[i % 3]); + } + CatsBatch::bind( + CatsSchema::new(42, 0), + std::array::from_fn(|i| input[i].as_slice()), + ) + .unwrap() +} + +fn median_min(mut v: Vec) -> (f64, f64) { + v.sort_unstable(); + (v[v.len() / 2] as f64 / 1e3, v[0] as f64 / 1e3) +} + +fn main() { + let mut args = std::env::args().skip(1); + let n: usize = args.next().map_or(1 << 20, |s| s.parse().unwrap()); + let reps: usize = args.next().map_or(101, |s| s.parse().unwrap()); + let text = include_str!("../tests/fixtures/lego.methods"); + + let batch = batch(n); + let cats = CatsBinder::new(&batch); + let basis = canonical_fields(); + // FIXTURE: proves SAP's hypothesized `duration` claim, as the tests do. + let claims: Vec = SAP_FIELDS + .iter() + .map(|m| FieldMap { + grade: if m.canonical == "duration" { + Grade::Exact + } else { + m.grade + }, + ..*m + }) + .collect(); + let glove = Glove { + concept: BILLABLE_WORK_ENTRY, + basis: &basis, + claims: &claims, + binder: &cats, + table: TABLE, + reading: ReadingId(1), + universe: Universe(42), + }; + let live = live_words(n); + let lanes = batch.lanes(); + let planes = Planes { + n_rows: n, + masks: &[live.as_slice()], + lanes: &lanes, + }; + let (LaneRef::U32(bill), LaneRef::I32(hours)) = (lanes[BILLABLE], lanes[HOURS]) else { + panic!("lane kinds") + }; + let want: i64 = bill + .iter() + .zip(hours) + .filter(|(b, _)| **b == 1) + .map(|(_, h)| i64::from(*h)) + .sum(); + + // Cold: text -> declarations -> prepared table (3 methods, 2 references). + let mut parse = Vec::new(); + let mut prep = Vec::new(); + for _ in 0..reps { + let t0 = Instant::now(); + let d = black_box(parse_decls(text).unwrap()); + parse.push(t0.elapsed().as_nanos()); + let t1 = Instant::now(); + black_box(MethodTable::prepare_with(&glove, &[], &d)); + prep.push(t1.elapsed().as_nanos()); + } + let decls = parse_decls(text).unwrap(); + let t = MethodTable::prepare_with(&glove, &[], &decls); + let key = t.resolve("billable_hours").unwrap(); + let expect = Answer::Values(vec![FoldValue::Scalar(want)]); + for f in [Frontend::Quack, Frontend::R2il] { + assert_eq!(t.call(key, &planes, Universe(42), f).unwrap(), expect); + } + // Direct: the prepared method's own program, run without the method + // layer. The composition test pins that this is also the program a + // direct Quack query lowers to. + let program = t.get(key).unwrap().as_ref().unwrap().programs()[0].clone(); + let mut scratch = Scratch::for_program(&program, n).unwrap(); + let mut direct = || match execute_into( + &program, + &planes, + &Foreign::NONE, + &mut scratch, + Out::None, + ) + .unwrap() + { + Value::SumI64(s) => s, + v => panic!("{v:?}"), + }; + assert_eq!(direct(), want); + + let mut warm: [Vec; 3] = Default::default(); + for r in 0..reps { + for k in 0..3 { + let path = (r + k) % 3; + let t0 = Instant::now(); + match path { + 0 => { + black_box(t.call(key, &planes, Universe(42), Frontend::Quack).unwrap()); + } + 1 => { + black_box(t.call(key, &planes, Universe(42), Frontend::R2il).unwrap()); + } + _ => { + black_box(direct()); + } + } + warm[path].push(t0.elapsed().as_nanos()); + } + } + println!( + "tier: avx512f={} avx2={} (compiled target features)", + cfg!(target_feature = "avx512f"), + cfg!(target_feature = "avx2") + ); + println!("rows {n}, reps {reps}, answer {want}"); + let row = |name: &str, (med, min): (f64, f64)| { + println!("{name:<40} median {med:>10.2} µs min {min:>10.2} µs"); + }; + row("cold: parse 3 declarations", median_min(parse)); + row( + "cold: prepare_with (resolve+inline+lower)", + median_min(prep), + ); + let [q, r, d] = warm; + row("warm: composed billable_hours, Quack", median_min(q)); + row("warm: composed billable_hours, R2IL", median_min(r)); + row("warm: direct program (same Program)", median_min(d)); +} diff --git a/crates/lance-graph-glove-parity/src/decl.rs b/crates/lance-graph-glove-parity/src/decl.rs index f7dce36ff..f98a6503c 100644 --- a/crates/lance-graph-glove-parity/src/decl.rs +++ b/crates/lance-graph-glove-parity/src/decl.rs @@ -29,9 +29,10 @@ //! reading are checked against the glove at preparation, never trusted here. 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, MethodSpec, MethodTable, ReadingId, - SchemaSeal, + Cond, EffectClass, Fold, Glove, Law, MethodBody, MethodKey, MethodRef, MethodSpec, MethodTable, + ReadingId, SchemaSeal, }; /// A method declared in text: a [`MethodSpec`] with owned storage. @@ -49,10 +50,14 @@ pub struct MethodDecl { } #[derive(Debug, Clone, PartialEq, Eq)] -struct DeclCond { - field: String, - literal: String, - negate: bool, +enum DeclCond { + Field { + field: String, + literal: String, + negate: bool, + }, + /// A predicate method, by label; resolved at preparation. + Ref(String), } #[derive(Debug, Clone, PartialEq, Eq)] @@ -70,13 +75,14 @@ impl MethodDecl { fn conds(&self) -> Vec> { self.filter .iter() - .map(|c| { - let (field, literal) = (c.field.as_str(), c.literal.as_str()); - if c.negate { - Cond::Ne { field, literal } - } else { - Cond::Eq { field, literal } - } + .map(|c| match c { + DeclCond::Field { + field, + literal, + negate: true, + } => Cond::Ne { field, literal }, + DeclCond::Field { field, literal, .. } => Cond::Eq { field, literal }, + DeclCond::Ref(label) => Cond::Method(MethodRef::Label(label)), }) .collect() } @@ -96,19 +102,31 @@ impl MethodDecl { .collect() } + /// What the method may do. A declaration can only express a pure query + /// or a pure predicate: no grammar form loads an effect, so loading + /// grants no authority. + #[must_use] + pub fn effect(&self) -> EffectClass { + EffectClass::Pure + } + /// The spec this declaration is, borrowing `conds` and `folds` (its own - /// conditions and folds as views). + /// conditions and folds as views). No folds is a predicate. fn spec<'a>(&self, conds: &'a [Cond<'a>], folds: &'a [Fold<'a>]) -> MethodSpec<'a> { MethodSpec { key: self.key, concept: self.concept, schema: self.schema, reading: self.reading, - effect: EffectClass::Pure, - body: MethodBody::Query { - filter: conds, - folds, - law: Law::Values, + effect: self.effect(), + body: if folds.is_empty() { + MethodBody::Select { filter: conds } + } else { + MethodBody::Query { + filter: conds, + folds, + law: Law::Values, + } }, } } @@ -152,6 +170,13 @@ pub enum DeclError { Unsupported { line: usize, what: String }, /// OGAR's codebook has no concept of that name. UnknownConcept { line: usize, name: String }, + /// A Gremlin step that changes the world. No declaration can carry one. + Effect { line: usize, step: String }, + /// The steps parse, but break the composer's anchor contract. + Refused { + line: usize, + refusal: GremlinRefusal, + }, } /// Parse every declaration in `text`. @@ -228,13 +253,17 @@ fn parse_line(line: &str, n: usize) -> Result { line: n, name: concept.to_owned(), })?; - let toks = tokenize(body).map_err(|m| syntax(&m))?; - let (folds, filter) = Parser { - toks: &toks, - at: 0, - line: n, - } - .body()?; + let (folds, filter) = if body.trim_start().starts_with("V(") { + traversal(body.trim(), concept_id, n)? + } else { + let toks = tokenize(body).map_err(|m| syntax(&m))?; + Parser { + toks: &toks, + at: 0, + line: n, + } + .body()? + }; Ok(MethodDecl { label: label.to_owned(), concept: concept_id, @@ -461,7 +490,7 @@ impl<'t> Parser<'t> { Some(Tok::Str(s)) => s.clone(), _ => return Err(self.syntax("expected a literal")), }; - Ok(DeclCond { + Ok(DeclCond::Field { field, literal, negate, @@ -474,3 +503,252 @@ fn is_keyword(w: &str) -> bool { .iter() .any(|k| w.eq_ignore_ascii_case(k)) } + +// ---------------------------------------------------------------- Gremlin form + +/// Steps that change the world (TinkerPop 3.7.7). Refused as effects, never +/// as syntax, so a caller can tell "not allowed" from "not understood". +const EFFECT_STEPS: [&str; 13] = [ + "addV", + "addE", + "property", + "drop", + "mergeV", + "mergeE", + "sideEffect", + "aggregate", + "store", + "sack", + "inject", + "io", + "call", +]; + +/// `V(concept).step(args)…`, parsed into typed [`Step`]s and composed by +/// [`compose`]; the result is copied into owned conditions and folds. +fn traversal( + body: &str, + concept: u16, + n: usize, +) -> Result<(Vec, Vec), DeclError> { + let syntax = |msg: &str| DeclError::Syntax { + line: n, + msg: msg.to_owned(), + }; + let unsupported = |what: &str| DeclError::Unsupported { + line: n, + what: what.to_owned(), + }; + let mut steps = Vec::new(); + for (name, args) in calls(body).ok_or_else(|| syntax("expected `name(args).name(args)…`"))? { + let arity = |k: usize| { + let a = split_args(args); + if a.len() == k { + Ok(a) + } else { + Err(syntax(&format!("{name} takes {k} argument(s)"))) + } + }; + let field = |s: &str| { + if ident(s) { + Ok(s.to_owned()) + } else { + Err(syntax("expected a field name")) + } + }; + if EFFECT_STEPS.contains(&name) { + return Err(DeclError::Effect { + line: n, + step: name.to_owned(), + }); + } + steps.push(match name { + "V" => { + let [c] = arity(1)?[..] else { unreachable!() }; + Step::V( + canonical_concept_id(c).ok_or_else(|| DeclError::UnknownConcept { + line: n, + name: c.to_owned(), + })?, + ) + } + "has" => { + let [f, p] = arity(2)?[..] else { + unreachable!() + }; + field(f)?; + let pred = match calls(p) { + Some(c) if c.len() == 1 => { + let (pn, pa) = c[0]; + let [lit] = split_args(pa)[..] else { + return Err(syntax("a predicate takes one literal")); + }; + match pn { + "eq" => Pred::Eq(literal(lit).ok_or_else(|| syntax("literal"))?), + "neq" => Pred::Neq(literal(lit).ok_or_else(|| syntax("literal"))?), + other => return Err(unsupported(other)), + } + } + Some(_) => return Err(syntax("one predicate")), + None => Pred::Eq(literal(p).ok_or_else(|| syntax("expected a literal"))?), + }; + Step::Has { field: f, pred } + } + "where" => { + let [inner] = arity(1)?[..] else { + unreachable!() + }; + match calls(inner).as_deref() { + Some([(label, "")]) if !is_step_name(label) => { + Step::Where(MethodRef::Label(label)) + } + Some([(other, _), ..]) => return Err(unsupported(other)), + _ => return Err(syntax("where takes a method reference")), + } + } + "values" => { + let [f] = arity(1)?[..] else { unreachable!() }; + field(f)?; + Step::Values(f) + } + "out" | "in" | "both" => { + let [r] = arity(1)?[..] else { unreachable!() }; + field(r)?; + match name { + "out" => Step::Out(r), + "in" => Step::In(r), + _ => Step::Both(r), + } + } + "dedup" | "count" | "sum" => { + if !args.trim().is_empty() { + return Err(syntax(&format!("{name} takes no arguments"))); + } + match name { + "dedup" => Step::Dedup, + "count" => Step::Count, + _ => Step::Sum, + } + } + other => return Err(unsupported(other)), + }); + } + let composed = + compose(concept, &steps).map_err(|refusal| DeclError::Refused { line: n, refusal })?; + let filter = composed + .filter() + .iter() + .map(|c| match *c { + Cond::Eq { field, literal } => DeclCond::Field { + field: field.to_owned(), + literal: literal.to_owned(), + negate: false, + }, + Cond::Ne { field, literal } => DeclCond::Field { + field: field.to_owned(), + literal: literal.to_owned(), + negate: true, + }, + Cond::Method(MethodRef::Label(l)) => DeclCond::Ref(l.to_owned()), + Cond::Method(MethodRef::Key(_)) => unreachable!("text names methods by label"), + }) + .collect(); + let folds = composed + .folds() + .iter() + .map(|f| match *f { + Fold::Count => DeclFold::Count, + Fold::Sum(field) => DeclFold::Sum(field.to_owned()), + Fold::GroupSum { key, val, groups } => DeclFold::GroupSum { + key: key.to_owned(), + val: val.to_owned(), + groups, + }, + }) + .collect(); + Ok((folds, filter)) +} + +/// A Gremlin step name: refused if used as a method label in `where`. +fn is_step_name(s: &str) -> bool { + EFFECT_STEPS.contains(&s) + || [ + "V", "has", "where", "values", "out", "in", "both", "dedup", "count", "sum", "not", + "and", "or", + ] + .contains(&s) +} + +/// `a(x).b(y)` → `[("a","x"), ("b","y")]`; arguments keep nested parentheses. +/// `None` if the text is not a dot-separated chain of calls. +fn calls(s: &str) -> Option> { + let mut out = Vec::new(); + let mut rest = s.trim(); + loop { + let open = rest.find('(')?; + let name = rest[..open].trim(); + if !ident(name) { + return None; + } + let (mut depth, mut quoted) = (0usize, false); + let mut close = None; + for (i, c) in rest[open..].char_indices() { + match c { + '"' => quoted = !quoted, + _ if quoted => {} + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + close = Some(open + i); + break; + } + } + _ => {} + } + } + let close = close?; + out.push((name, rest[open + 1..close].trim())); + rest = rest[close + 1..].trim_start(); + if rest.is_empty() { + return Some(out); + } + rest = rest.strip_prefix('.')?; + } +} + +/// Top-level comma split; empty input is no arguments. +fn split_args(s: &str) -> Vec<&str> { + if s.trim().is_empty() { + return Vec::new(); + } + let mut out = Vec::new(); + let (mut depth, mut start, mut quoted) = (0usize, 0usize, false); + for (i, c) in s.char_indices() { + match c { + '"' => quoted = !quoted, + _ if quoted => {} + '(' => depth += 1, + ')' => depth = depth.saturating_sub(1), + ',' if depth == 0 => { + out.push(s[start..i].trim()); + start = i + 1; + } + _ => {} + } + } + out.push(s[start..].trim()); + out +} + +/// A bare word or a double-quoted string. +fn literal(s: &str) -> Option<&str> { + let s = s.trim(); + if let Some(inner) = s.strip_prefix('"').and_then(|r| r.strip_suffix('"')) { + return Some(inner); + } + (!s.is_empty() + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-'))) + .then_some(s) +} diff --git a/crates/lance-graph-glove-parity/src/gremlin.rs b/crates/lance-graph-glove-parity/src/gremlin.rs new file mode 100644 index 000000000..ae83315fd --- /dev/null +++ b/crates/lance-graph-glove-parity/src/gremlin.rs @@ -0,0 +1,198 @@ +//! Gremlin steps as typed building blocks of a class method (D-CMH-G1). +//! +//! A traversal is a slice of [`Step`]s. [`compose`] turns it into a +//! [`MethodBody`] in the concept's canonical field names: conditions, folds, +//! or a predicate. After that it is an ordinary method: [`MethodTable`] +//! binds it, inlines the predicates it names, checks claims and laws, and +//! Quack lowers it. No step is interpreted at execution time, and nothing +//! here evaluates a row. +//! +//! [`MethodTable`]: crate::method::MethodTable +//! +//! # The anchor contract +//! +//! The traversal starts at `V(concept)`: the concept's table, where each live +//! row is one vertex and one traverser of bulk 1 (the anchor rule of +//! `crates/lance-graph-quack/tests/gremlin_parity.rs`, #1311). Every step this +//! slice accepts keeps that true: +//! +//! | step | on a vertex | on `values(f)` | +//! |---|---|---| +//! | `has(f, eq/neq)`, `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)` | +//! | `out` / `in` / `both` | refused (`NoRelationCarrier`): the glove declares no relation carrier | — | +//! +//! 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 +//! 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}; + +/// A predicate in `has(field, pred)`, over a coded field's literal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Pred<'a> { + /// `P.eq(literal)`. + Eq(&'a str), + /// `P.neq(literal)`. + Neq(&'a str), +} + +/// One traversal step. Names are canonical: a concept id, the concept's +/// canonical field names, method references, relation labels. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Step<'a> { + /// `V()` over a concept's population. + V(u16), + /// `has(field, pred)`. + Has { field: &'a str, pred: Pred<'a> }, + /// `where(method())`: a predicate method of the same concept. + Where(MethodRef<'a>), + /// `values(field)`. + Values(&'a str), + /// `dedup()`. + Dedup, + /// `count()`. + Count, + /// `sum()`. + Sum, + /// `out(relation)`. + Out(&'a str), + /// `in(relation)`. + In(&'a str), + /// `both(relation)`. + Both(&'a str), +} + +/// Why a traversal does not compose. Each names the missing capability or +/// the contract it would break. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GremlinRefusal { + /// The traversal does not start with exactly one `V(concept)`. + 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), + /// `dedup()` after `values(f)`: distinct values, a different multiset. + DedupOfValues, + /// `count()` after `values(f)`: `COUNT(f)`, no such fold yet. + CountOfValues, + /// `sum()` of vertices. + SumOfVertices, + /// A filter after `values(f)`. + FilterAfterValues, + /// `values(f)` after `values(g)`. + ValuesOfValues, + /// A step after the terminal fold. + AfterTerminal, + /// The traversal ends on a stream of values. + NoTerminal, +} + +/// A composed traversal: the body's conditions and folds, owned. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Composed<'a> { + filter: Vec>, + folds: Vec>, +} + +impl<'a> Composed<'a> { + /// The body: a predicate when the traversal ends on vertices, otherwise + /// a query finished by [`Law::Values`]. + #[must_use] + pub fn body(&self) -> MethodBody<'_> { + if self.folds.is_empty() { + MethodBody::Select { + filter: &self.filter, + } + } else { + MethodBody::Query { + filter: &self.filter, + folds: &self.folds, + law: Law::Values, + } + } + } + + /// The conditions, in step order. + #[must_use] + pub fn filter(&self) -> &[Cond<'a>] { + &self.filter + } + + /// The folds; empty for a predicate. + #[must_use] + pub fn folds(&self) -> &[Fold<'a>] { + &self.folds + } +} + +enum Cursor<'a> { + Vertex, + Value(&'a str), + Done, +} + +/// Compose `steps` into the body of a method of `concept`. +/// +/// # Errors +/// +/// [`GremlinRefusal`] naming the first step that breaks the anchor contract. +pub fn compose<'a>(concept: u16, steps: &[Step<'a>]) -> Result, GremlinRefusal> { + let (first, rest) = steps.split_first().ok_or(GremlinRefusal::NoStart)?; + match *first { + Step::V(c) if c == concept => {} + Step::V(c) => return Err(GremlinRefusal::AnchorMismatch { step: c, concept }), + _ => return Err(GremlinRefusal::NoStart), + } + let mut filter = Vec::new(); + let mut folds = Vec::new(); + let mut cursor = Cursor::Vertex; + for &step in rest { + if matches!(cursor, Cursor::Done) { + return Err(GremlinRefusal::AfterTerminal); + } + 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::Has { field, pred }, Cursor::Vertex) => { + filter.push(match pred { + Pred::Eq(literal) => Cond::Eq { field, literal }, + Pred::Neq(literal) => Cond::Ne { field, literal }, + }); + Cursor::Vertex + } + (Step::Where(r), Cursor::Vertex) => { + filter.push(Cond::Method(r)); + Cursor::Vertex + } + (Step::Has { .. } | Step::Where(_), _) => { + return Err(GremlinRefusal::FilterAfterValues) + } + (Step::Dedup, Cursor::Vertex) => Cursor::Vertex, + (Step::Dedup, _) => return Err(GremlinRefusal::DedupOfValues), + (Step::Values(f), Cursor::Vertex) => Cursor::Value(f), + (Step::Values(_), _) => return Err(GremlinRefusal::ValuesOfValues), + (Step::Count, Cursor::Vertex) => { + folds.push(Fold::Count); + Cursor::Done + } + (Step::Count, _) => return Err(GremlinRefusal::CountOfValues), + (Step::Sum, Cursor::Value(f)) => { + folds.push(Fold::Sum(f)); + Cursor::Done + } + (Step::Sum, _) => return Err(GremlinRefusal::SumOfVertices), + }; + } + if matches!(cursor, Cursor::Value(_)) { + return Err(GremlinRefusal::NoTerminal); + } + Ok(Composed { filter, folds }) +} diff --git a/crates/lance-graph-glove-parity/src/lib.rs b/crates/lance-graph-glove-parity/src/lib.rs index 3ba748ae8..e4f9a94ea 100644 --- a/crates/lance-graph-glove-parity/src/lib.rs +++ b/crates/lance-graph-glove-parity/src/lib.rs @@ -14,6 +14,7 @@ pub mod basis; pub mod call; pub mod decl; +pub mod gremlin; pub mod method; use lance_graph_ontology::namespace::SchemaKind; diff --git a/crates/lance-graph-glove-parity/src/method.rs b/crates/lance-graph-glove-parity/src/method.rs index e1f3c2d82..ddf2b1800 100644 --- a/crates/lance-graph-glove-parity/src/method.rs +++ b/crates/lance-graph-glove-parity/src/method.rs @@ -112,6 +112,18 @@ pub enum EffectClass { ExternalMutate, } +/// How a body names another method of the same table. +/// +/// A key is the method's identity. A label is display text, resolved to a +/// key once, at preparation, by the same rule [`MethodTable::resolve`] uses +/// (an ambiguous label names nothing); it is how an authoring format refers +/// to a method it was not compiled against. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MethodRef<'a> { + Key(MethodKey), + Label(&'a str), +} + /// One condition of a body's `WHERE`, on a canonical field. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Cond<'a> { @@ -119,12 +131,17 @@ pub enum Cond<'a> { Eq { field: &'a str, literal: &'a str }, /// `field != literal`. Ne { field: &'a str, literal: &'a str }, + /// 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. + Method(MethodRef<'a>), } impl<'a> Cond<'a> { - fn field(self) -> &'a str { + fn field(self) -> Option<&'a str> { match self { - Cond::Eq { field, .. } | Cond::Ne { field, .. } => field, + Cond::Eq { field, .. } | Cond::Ne { field, .. } => Some(field), + Cond::Method(_) => None, } } } @@ -212,6 +229,10 @@ pub enum MethodBody<'a> { folds: &'a [Fold<'a>], law: Law<'a>, }, + /// A predicate: `WHERE filter`, folding nothing. It has no value of its + /// own; other bodies use it through [`Cond::Method`], and a pure call of + /// it is refused ([`CallError::NotTerminal`]). + Select { filter: &'a [Cond<'a>] }, /// An OGAR action. Named here; never executed here. Action { action: &'a str }, } @@ -238,10 +259,15 @@ impl<'a> MethodSpec<'a> { #[must_use] pub fn reads(&self) -> Vec<&'a str> { let mut out = Vec::new(); - if let MethodBody::Query { filter, folds, .. } = self.body { + let (filter, folds): (&[Cond<'a>], &[Fold<'a>]) = match self.body { + MethodBody::Query { filter, folds, .. } => (filter, folds), + MethodBody::Select { filter } => (filter, &[]), + MethodBody::Action { .. } => (&[], &[]), + }; + { let fields = filter .iter() - .map(|c| c.field()) + .filter_map(|c| c.field()) .chain(folds.iter().flat_map(|f| match *f { Fold::Count => vec![], Fold::Sum(field) => vec![field], @@ -304,6 +330,16 @@ pub enum PrepareError { /// Two proven claims map different native fields onto this canonical /// field; binding either would depend on claim order. AmbiguousClaim(String), + /// A [`Cond::Method`] names no method of this table (the key in hex, or + /// the label; an ambiguous label names nothing). + UnknownMethodRef(String), + /// A [`Cond::Method`] names a method that is not a predicate. + MethodRefNotPredicate(MethodKey), + /// Following references returns to this method. + MethodCycle(MethodKey), + /// A [`Cond::Method`] names a predicate written for another concept, + /// basis, reading or effect than the caller. + MethodRefContract(MethodKey), } /// Why a call did not run. @@ -315,6 +351,8 @@ pub enum CallError { Refused(PrepareError), /// The method is an action; a pure call never runs it. NotPure, + /// The method is a predicate; it selects rows and has no value. + NotTerminal, /// The planes belong to another population than the method was /// prepared against. UniverseMismatch { @@ -351,6 +389,7 @@ enum Bound { /// The Quack programs, lowered once at preparation, one per fold. programs: Vec, }, + Select, Action, } @@ -403,7 +442,7 @@ impl PreparedMethod { pub fn programs(&self) -> &[Program] { match &self.bound { Bound::Query { programs, .. } => programs, - Bound::Action => &[], + Bound::Select | Bound::Action => &[], } } } @@ -458,7 +497,10 @@ impl PreparedMethod { programs, } = &self.bound else { - return Err(CallError::NotPure); + return Err(match self.bound { + Bound::Select => CallError::NotTerminal, + _ => CallError::NotPure, + }); }; if universe != self.universe { return Err(CallError::UniverseMismatch { @@ -607,7 +649,8 @@ impl MethodTable { } else if count(&|(m, _)| m == l) > 1 { Err(PrepareError::DuplicateLabel((*l).to_owned())) } else { - glove.prepare(seal, available.as_ref(), s) + inline_methods(specs, s) + .and_then(|s| glove.prepare(seal, available.as_ref(), &s.spec())) }; (s.key, prepared) }) @@ -662,6 +705,120 @@ impl MethodTable { } } +/// A spec with every [`Cond::Method`] replaced by the predicate's conditions. +struct Inlined<'a> { + spec: MethodSpec<'a>, + filter: Vec>, +} + +impl Inlined<'_> { + fn spec(&self) -> MethodSpec<'_> { + let body = match self.spec.body { + MethodBody::Query { folds, law, .. } => MethodBody::Query { + filter: &self.filter, + folds, + law, + }, + MethodBody::Select { .. } => MethodBody::Select { + filter: &self.filter, + }, + action @ MethodBody::Action { .. } => action, + }; + MethodSpec { body, ..self.spec } + } +} + +/// Resolve `spec`'s method references against `specs` and inline them. +/// Cold, once per spec; depth is bounded by the cycle check. +fn inline_methods<'a>( + specs: &[(&str, MethodSpec<'a>)], + spec: &MethodSpec<'a>, +) -> Result, PrepareError> { + let filter = match spec.body { + MethodBody::Query { filter, .. } | MethodBody::Select { filter } => filter, + MethodBody::Action { .. } => &[], + }; + let mut out = Vec::with_capacity(filter.len()); + let mut stack = vec![spec.key]; + expand(specs, spec, filter, &mut stack, &mut out)?; + Ok(Inlined { + spec: *spec, + filter: out, + }) +} + +fn expand<'a>( + specs: &[(&str, MethodSpec<'a>)], + caller: &MethodSpec<'a>, + filter: &[Cond<'a>], + stack: &mut Vec, + out: &mut Vec>, +) -> Result<(), PrepareError> { + for c in filter { + let Cond::Method(r) = *c else { + out.push(*c); + continue; + }; + let target = resolve_ref(specs, r)?; + if stack.contains(&target.key) { + return Err(PrepareError::MethodCycle(target.key)); + } + let MethodBody::Select { filter: inner } = target.body else { + return Err(PrepareError::MethodRefNotPredicate(target.key)); + }; + if (target.concept, target.schema, target.reading, target.effect) + != ( + caller.concept, + caller.schema, + caller.reading, + EffectClass::Pure, + ) + { + return Err(PrepareError::MethodRefContract(target.key)); + } + stack.push(target.key); + expand(specs, caller, inner, stack, out)?; + stack.pop(); + } + Ok(()) +} + +fn resolve_ref<'a, 'b>( + specs: &'b [(&str, MethodSpec<'a>)], + r: MethodRef<'_>, +) -> Result<&'b MethodSpec<'a>, PrepareError> { + let (hits, name): (Vec<&MethodSpec<'a>>, String) = match r { + MethodRef::Key(k) => ( + specs + .iter() + .filter(|(_, s)| s.key == k) + .map(|(_, s)| s) + .collect(), + k.0.iter().map(|b| format!("{b:02x}")).collect(), + ), + MethodRef::Label(l) => ( + specs + .iter() + .filter(|(m, _)| *m == l) + .map(|(_, s)| s) + .collect(), + l.to_owned(), + ), + }; + match (hits.as_slice(), r) { + // A unique label is not enough: the key behind it must be unique + // too, or the reference would inline a method `prepare` refuses. + ([one], MethodRef::Label(_)) + if specs.iter().filter(|(_, s)| s.key == one.key).count() > 1 => + { + Err(PrepareError::DuplicateKey(one.key)) + } + ([one], _) => Ok(one), + ([_, _, ..], MethodRef::Key(k)) => Err(PrepareError::DuplicateKey(k)), + _ => Err(PrepareError::UnknownMethodRef(name)), + } +} + /// Basis positions with a proven claim (`Exact` or `Converted`). fn available(basis: &[String], claims: &[FieldMap]) -> Result { let mut mask = WideFieldMask::EMPTY; @@ -785,6 +942,16 @@ impl Glove<'_> { programs, } } + (MethodBody::Select { filter }, EffectClass::Pure) => { + let t = self + .binder + .table(self.table) + .ok_or_else(|| PrepareError::UnknownTable(self.table.to_owned()))?; + for c in filter { + self.cond(t, *c)?; + } + Bound::Select + } (MethodBody::Action { .. }, EffectClass::Propose | EffectClass::ExternalMutate) => { Bound::Action } @@ -847,6 +1014,11 @@ impl Glove<'_> { let (field, literal, negate) = match c { Cond::Eq { field, literal } => (field, literal, false), Cond::Ne { field, literal } => (field, literal, true), + // 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)) => { + return Err(PrepareError::UnknownMethodRef(l.to_owned())) + } }; let b = self.field(t, field)?; if b.kind != FieldKind::Code { diff --git a/crates/lance-graph-glove-parity/tests/common/mod.rs b/crates/lance-graph-glove-parity/tests/common/mod.rs new file mode 100644 index 000000000..53550e208 --- /dev/null +++ b/crates/lance-graph-glove-parity/tests/common/mod.rs @@ -0,0 +1,116 @@ +//! Shared fixture for `tests/compose.rs` and `tests/gremlin.rs`: the SAP CATS +//! glove of `billable_work_entry`, the same batch the other suites build, and +//! a row-loop oracle that reads the indicator text, never a bound lane. +#![allow(dead_code)] + +use lance_graph_glove_parity::basis::{canonical_fields, FieldMap, Grade, SAP_FIELDS}; +use lance_graph_glove_parity::method::{ + Answer, CallError, FoldValue, Frontend, Glove, MethodKey, MethodTable, ReadingId, SchemaSeal, + Universe, +}; +use lance_graph_mask_risc::{LaneRef, Planes}; +use lance_graph_ogar::ogar_vocab::class_ids::BILLABLE_WORK_ENTRY; +use lance_graph_quack::bind::Binder; +use lance_graph_sap::bind::{billable_code, CatsBatch, BILLING, HOURS}; +use lance_graph_sap::binder::{live_words, TABLE}; +use lance_graph_sap::schema::{CatsSchema, FIELD_COUNT}; + +/// FIXTURES: the reading id and population id a real glove is issued. +pub const READING: ReadingId = ReadingId(1); +pub const UNIVERSE: Universe = Universe(42); +pub const SIZES: [usize; 4] = [1, 64, 65, 4097]; + +const INDICATORS: [&str; 4] = ["Billable", "Non-Billable", "Internal Cost", "Unknown"]; +const HOURS_TEXT: [&str; 3] = ["8.50", "0.125", "2"]; + +pub fn indicator(i: usize) -> &'static str { + INDICATORS[(i * 7 + i / 3) % 4] +} + +pub fn batch(n: usize) -> CatsBatch { + let base: Vec> = include_str!("../../../lance-graph-sap/fixtures/cats.txt") + .lines() + .map(|v| if v == "\\N" { None } else { Some(v) }) + .collect(); + let mut input: [Vec>; FIELD_COUNT] = std::array::from_fn(|i| vec![base[i]; n]); + for i in 0..n { + input[BILLING][i] = Some(indicator(i)); + input[HOURS][i] = Some(HOURS_TEXT[i % 3]); + } + CatsBatch::bind( + CatsSchema::new(42, 0), + std::array::from_fn(|i| input[i].as_slice()), + ) + .unwrap() +} + +/// FIXTURE: proves SAP's `Hypothesized` claim on `duration`, as the other +/// suites do. Nothing in `src/` does this. +pub fn with_duration() -> Vec { + SAP_FIELDS + .iter() + .map(|m| FieldMap { + grade: if m.canonical == "duration" { + Grade::Exact + } else { + m.grade + }, + ..*m + }) + .collect() +} + +pub fn seal() -> SchemaSeal { + SchemaSeal::of(&canonical_fields()) +} + +pub fn glove<'a>(basis: &'a [String], binder: &'a dyn Binder, claims: &'a [FieldMap]) -> Glove<'a> { + Glove { + concept: BILLABLE_WORK_ENTRY, + basis, + claims, + binder, + table: TABLE, + reading: READING, + universe: UNIVERSE, + } +} + +pub fn call( + t: &MethodTable, + batch: &CatsBatch, + key: MethodKey, + f: Frontend, +) -> Result { + let n = batch.len(); + let live = live_words(n); + let lanes = batch.lanes(); + let planes = Planes { + n_rows: n, + masks: &[live.as_slice()], + lanes: &lanes, + }; + t.call(key, &planes, UNIVERSE, f) +} + +pub fn scalars(v: &[i64]) -> Answer { + Answer::Values(v.iter().map(|&n| FoldValue::Scalar(n)).collect()) +} + +/// The oracle's rows: `(billing code from the indicator text, hours)`. +pub fn rows(batch: &CatsBatch) -> Vec<(u32, i64)> { + let LaneRef::I32(hours) = batch.lanes()[HOURS] else { + panic!("hours is an I32 lane") + }; + (0..batch.len()) + .map(|i| (billable_code(indicator(i)), i64::from(hours[i]))) + .collect() +} + +/// `(count, sum of hours)` over rows whose code satisfies `keep`. +pub fn count_sum(batch: &CatsBatch, keep: impl Fn(u32) -> bool) -> (i64, i64) { + rows(batch) + .into_iter() + .filter(|(c, _)| keep(*c)) + .fold((0, 0), |(n, s), (_, h)| (n + 1, s + h)) +} diff --git a/crates/lance-graph-glove-parity/tests/compose.rs b/crates/lance-graph-glove-parity/tests/compose.rs new file mode 100644 index 000000000..8dd8f3cc9 --- /dev/null +++ b/crates/lance-graph-glove-parity/tests/compose.rs @@ -0,0 +1,418 @@ +//! D-CMH-G1: one method reused inside another. +//! +//! A predicate method (`MethodBody::Select`) declares a selection and folds +//! nothing. Another method names it with `Cond::Method`; preparation resolves +//! the reference once and joins the predicate's conditions into the caller's +//! conjunction before binding. The caller then lowers to exactly the program a +//! hand-written conjunction lowers to: there is no call at run time, no second +//! evaluator, and no Rust per method. +//! +//! Keys and labels are FIXTURES. +mod common; + +use common::*; +use lance_graph_glove_parity::basis::canonical_fields; +use lance_graph_glove_parity::method::{ + CallError, Cond, EffectClass, Fold, Frontend, Law, MethodBody, MethodKey, MethodRef, + MethodSpec, MethodTable, PrepareError, ReadingId, SchemaSeal, +}; +use lance_graph_ogar::ogar_vocab::class_ids::BILLABLE_WORK_ENTRY; +use lance_graph_sap::binder::CatsBinder; + +const IS_BILLABLE: MethodKey = MethodKey([0x11; 16]); +const BILLABLE_HOURS: MethodKey = MethodKey([0x12; 16]); +const BILLABLE_COUNT: MethodKey = MethodKey([0x13; 16]); +const DIRECT_HOURS: MethodKey = MethodKey([0x14; 16]); + +fn spec(key: MethodKey, body: MethodBody<'static>) -> MethodSpec<'static> { + MethodSpec { + key, + concept: BILLABLE_WORK_ENTRY, + schema: seal(), + reading: READING, + effect: EffectClass::Pure, + body, + } +} + +const fn values( + filter: &'static [Cond<'static>], + folds: &'static [Fold<'static>], +) -> MethodBody<'static> { + MethodBody::Query { + filter, + folds, + law: Law::Values, + } +} + +const USES_IS_BILLABLE: &[Cond<'static>] = &[Cond::Method(MethodRef::Key(IS_BILLABLE))]; + +/// The three methods of the Lego example, plus the hand-written twin of the +/// sum, keyed `DIRECT_HOURS`. `predicate` is `is_billable`'s condition. +fn lego(predicate: &'static [Cond<'static>]) -> Vec<(&'static str, MethodSpec<'static>)> { + vec![ + ( + "is_billable", + spec(IS_BILLABLE, MethodBody::Select { filter: predicate }), + ), + ( + "billable_hours", + spec( + BILLABLE_HOURS, + values(USES_IS_BILLABLE, &[Fold::Sum("duration")]), + ), + ), + ( + "billable_count", + spec(BILLABLE_COUNT, values(USES_IS_BILLABLE, &[Fold::Count])), + ), + ( + "direct_hours", + spec( + DIRECT_HOURS, + values( + &[Cond::Eq { + field: "billable", + literal: "true", + }], + &[Fold::Sum("duration")], + ), + ), + ), + ] +} + +const BILLABLE: &[Cond<'static>] = &[Cond::Eq { + field: "billable", + literal: "true", +}]; +const NOT_BILLED: &[Cond<'static>] = &[Cond::Eq { + field: "billable", + literal: "false", +}]; + +/// FAILS IF: a method using a predicate by reference does not lower to the +/// program of the hand-written conjunction, or either consumer disagrees with +/// the row-loop oracle on either frontend. +#[test] +fn two_methods_reuse_one_predicate_method() { + for n in SIZES { + let batch = batch(n); + let cats = CatsBinder::new(&batch); + let (basis, claims) = (canonical_fields(), with_duration()); + let t = MethodTable::prepare(&glove(&basis, &cats, &claims), &lego(BILLABLE)); + let get = |k| t.get(k).unwrap().as_ref().unwrap(); + assert_eq!(get(BILLABLE_HOURS).programs(), get(DIRECT_HOURS).programs()); + assert_eq!(get(BILLABLE_HOURS).required, get(DIRECT_HOURS).required); + let (count, sum) = count_sum(&batch, |c| c == 1); + for f in [Frontend::Quack, Frontend::R2il] { + assert_eq!( + call(&t, &batch, BILLABLE_HOURS, f).unwrap(), + scalars(&[sum]), + "n={n}" + ); + assert_eq!( + call(&t, &batch, BILLABLE_COUNT, f).unwrap(), + scalars(&[count]), + "n={n}" + ); + } + } +} + +/// FAILS IF: the consumers copied the predicate instead of naming it: editing +/// only `is_billable` must move both of them. +#[test] +fn editing_the_predicate_moves_every_consumer() { + let batch = batch(4097); + let cats = CatsBinder::new(&batch); + let (basis, claims) = (canonical_fields(), with_duration()); + let t = MethodTable::prepare(&glove(&basis, &cats, &claims), &lego(NOT_BILLED)); + let (count, sum) = count_sum(&batch, |c| c == 0); + let (bcount, bsum) = count_sum(&batch, |c| c == 1); + assert!( + count != bcount && sum != bsum, + "non-vacuous: the predicates select differently" + ); + for f in [Frontend::Quack, Frontend::R2il] { + assert_eq!( + call(&t, &batch, BILLABLE_HOURS, f).unwrap(), + scalars(&[sum]) + ); + assert_eq!( + call(&t, &batch, BILLABLE_COUNT, f).unwrap(), + scalars(&[count]) + ); + } +} + +/// A predicate method selects rows and folds nothing: a pure call has no +/// value to return and is refused by name, never run as a count. +#[test] +fn a_predicate_method_is_not_a_terminal() { + let batch = batch(65); + let cats = CatsBinder::new(&batch); + let (basis, claims) = (canonical_fields(), with_duration()); + let t = MethodTable::prepare(&glove(&basis, &cats, &claims), &lego(BILLABLE)); + let m = t.get(IS_BILLABLE).unwrap().as_ref().unwrap(); + assert!(m.programs().is_empty()); + assert!(matches!( + call(&t, &batch, IS_BILLABLE, Frontend::Quack), + Err(CallError::NotTerminal) + )); +} + +/// A reference may chain: `is_billable_and_known` names `is_billable`, and a +/// fold names `is_billable_and_known`. Labels resolve exactly like keys. +#[test] +fn references_chain_and_resolve_by_label() { + const KNOWN: MethodKey = MethodKey([0x21; 16]); + const COUNT: MethodKey = MethodKey([0x22; 16]); + let specs = vec![ + ( + "is_billable", + spec(IS_BILLABLE, MethodBody::Select { filter: BILLABLE }), + ), + ( + "is_billable_and_known", + spec( + KNOWN, + MethodBody::Select { + filter: &[ + Cond::Method(MethodRef::Label("is_billable")), + Cond::Ne { + field: "billable", + literal: "false", + }, + ], + }, + ), + ), + ( + "count_known", + spec( + COUNT, + values(&[Cond::Method(MethodRef::Key(KNOWN))], &[Fold::Count]), + ), + ), + ]; + let batch = batch(4097); + let cats = CatsBinder::new(&batch); + let (basis, claims) = (canonical_fields(), with_duration()); + let t = MethodTable::prepare(&glove(&basis, &cats, &claims), &specs); + let (count, _) = count_sum(&batch, |c| c == 1); + for f in [Frontend::Quack, Frontend::R2il] { + assert_eq!(call(&t, &batch, COUNT, f).unwrap(), scalars(&[count])); + } +} + +fn refusal(specs: &[(&str, MethodSpec<'_>)], key: MethodKey) -> PrepareError { + let batch = batch(2); + let cats = CatsBinder::new(&batch); + let (basis, claims) = (canonical_fields(), with_duration()); + let t = MethodTable::prepare(&glove(&basis, &cats, &claims), specs); + t.get(key).unwrap().clone().unwrap_err() +} + +/// FAILS IF: a broken reference binds anyway. Each corruption is a typed +/// refusal of the CALLER at preparation. +#[test] +fn a_broken_reference_is_refused() { + const A: MethodKey = MethodKey([0xA1; 16]); + const B: MethodKey = MethodKey([0xA2; 16]); + const MISSING: MethodKey = MethodKey([0xEE; 16]); + let count_of = |filter: &'static [Cond<'static>]| values(filter, &[Fold::Count]); + // Unknown key, unknown label. + let s = [( + "a", + spec(A, count_of(&[Cond::Method(MethodRef::Key(MISSING))])), + )]; + assert_eq!( + refusal(&s, A), + PrepareError::UnknownMethodRef("ee".repeat(16)) + ); + let s = [( + "a", + spec(A, count_of(&[Cond::Method(MethodRef::Label("nope"))])), + )]; + assert_eq!( + refusal(&s, A), + PrepareError::UnknownMethodRef("nope".into()) + ); + // A reference to a fold, or to an action, is not a predicate. + let s = [ + ("a", spec(A, count_of(&[Cond::Method(MethodRef::Key(B))]))), + ("b", spec(B, values(BILLABLE, &[Fold::Count]))), + ]; + assert_eq!(refusal(&s, A), PrepareError::MethodRefNotPredicate(B)); + let s = [ + ("a", spec(A, count_of(&[Cond::Method(MethodRef::Key(B))]))), + ( + "b", + MethodSpec { + effect: EffectClass::ExternalMutate, + ..spec(B, MethodBody::Action { action: "submit" }) + }, + ), + ]; + assert_eq!(refusal(&s, A), PrepareError::MethodRefNotPredicate(B)); + // A cycle: self, and through a second predicate. + let s = [( + "a", + spec( + A, + MethodBody::Select { + filter: &[Cond::Method(MethodRef::Key(A))], + }, + ), + )]; + assert_eq!(refusal(&s, A), PrepareError::MethodCycle(A)); + let s = [ + ( + "a", + spec( + A, + MethodBody::Select { + filter: &[Cond::Method(MethodRef::Key(B))], + }, + ), + ), + ( + "b", + spec( + B, + MethodBody::Select { + filter: &[Cond::Method(MethodRef::Label("a"))], + }, + ), + ), + ( + "c", + spec( + MethodKey([0xA3; 16]), + count_of(&[Cond::Method(MethodRef::Key(A))]), + ), + ), + ]; + assert_eq!(refusal(&s, A), PrepareError::MethodCycle(A)); + assert_eq!( + refusal(&s, MethodKey([0xA3; 16])), + PrepareError::MethodCycle(A) + ); + // A predicate written for another basis or reading is not inlined into + // this one, even though the caller is fine. + for other in [ + MethodSpec { + schema: SchemaSeal(seal().0 ^ 1), + ..spec(B, MethodBody::Select { filter: BILLABLE }) + }, + MethodSpec { + reading: ReadingId(READING.0 + 1), + ..spec(B, MethodBody::Select { filter: BILLABLE }) + }, + ] { + let s = [ + ("a", spec(A, count_of(&[Cond::Method(MethodRef::Key(B))]))), + ("b", other), + ]; + assert_eq!(refusal(&s, A), PrepareError::MethodRefContract(B)); + } + // An ambiguous label names nothing; an ambiguous key is refused. + let s = [ + ( + "a", + spec(A, count_of(&[Cond::Method(MethodRef::Label("p"))])), + ), + ("p", spec(B, MethodBody::Select { filter: BILLABLE })), + ( + "p", + spec( + MethodKey([0xA4; 16]), + MethodBody::Select { filter: BILLABLE }, + ), + ), + ]; + assert_eq!(refusal(&s, A), PrepareError::UnknownMethodRef("p".into())); + // The predicate's own refusal is the caller's: an unknown field. + let s = [ + ("a", spec(A, count_of(&[Cond::Method(MethodRef::Key(B))]))), + ( + "b", + spec( + B, + MethodBody::Select { + filter: &[Cond::Eq { + field: "colour", + literal: "red", + }], + }, + ), + ), + ]; + assert_eq!(refusal(&s, A), PrepareError::UnknownField("colour".into())); + // Silence twin: a predicate that only reads `billable` binds against + // SAP's real claims, where `duration` is unproven. + let batch = batch(2); + let cats = CatsBinder::new(&batch); + let basis = canonical_fields(); + let t = MethodTable::prepare( + &glove(&basis, &cats, lance_graph_glove_parity::basis::SAP_FIELDS), + &lego(BILLABLE), + ); + assert!(t.get(BILLABLE_COUNT).unwrap().is_ok()); + assert_eq!( + t.get(BILLABLE_HOURS).unwrap(), + &Err(PrepareError::UnmappedRequired("duration".into())) + ); +} + +/// FAILS IF: a predicate of another class is captured because its field names +/// and positions coincide. `foreign` is identical to `is_billable` in seal, +/// reading, effect, field name and literal; only its concept differs. The +/// reference is refused, the caller does not prepare, and the result types +/// stay apart: only a predicate can be named, never a fold or an action. +#[test] +fn a_same_shaped_predicate_of_another_class_is_not_captured() { + use lance_graph_ogar::ogar_vocab::class_ids::DOCUMENT; + const FOREIGN: MethodKey = MethodKey([0xB7; 16]); + const USER: MethodKey = MethodKey([0xB8; 16]); + let foreign = MethodSpec { + concept: DOCUMENT, + ..spec(FOREIGN, MethodBody::Select { filter: BILLABLE }) + }; + let s = [ + ("foreign_is_billable", foreign), + ( + "count", + spec( + USER, + values(&[Cond::Method(MethodRef::Key(FOREIGN))], &[Fold::Count]), + ), + ), + ]; + assert_eq!(refusal(&s, USER), PrepareError::MethodRefContract(FOREIGN)); + // The foreign predicate is refused on its own, too: it is not a method of + // this table's concept. + assert_eq!( + refusal(&s, FOREIGN), + PrepareError::WrongConcept { + spec: DOCUMENT, + table: BILLABLE_WORK_ENTRY + } + ); + // Silence twin: the same spec with this table's concept is inlined. + let ours = MethodSpec { + concept: BILLABLE_WORK_ENTRY, + ..foreign + }; + let batch = batch(65); + let cats = CatsBinder::new(&batch); + let (basis, claims) = (canonical_fields(), with_duration()); + let t = MethodTable::prepare(&glove(&basis, &cats, &claims), &[("ours", ours), s[1]]); + let (c, _) = count_sum(&batch, |code| code == 1); + assert_eq!( + call(&t, &batch, USER, Frontend::Quack).unwrap(), + scalars(&[c]) + ); +} diff --git a/crates/lance-graph-glove-parity/tests/fixtures/lego.methods b/crates/lance-graph-glove-parity/tests/fixtures/lego.methods new file mode 100644 index 000000000..f56f576cc --- /dev/null +++ b/crates/lance-graph-glove-parity/tests/fixtures/lego.methods @@ -0,0 +1,6 @@ +# The Lego example of D-CMH-G1, as text loaded at run time. Two consumers name +# one predicate method; none of the three exists in compiled code. Keys are +# FIXTURES; `schema` is the canonical basis seal, checked at preparation. +billable_work_entry::is_billable key=b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1 schema=00420936c94ff753 reading=1 := V(billable_work_entry).has(billable, true) +billable_work_entry::billable_hours key=b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2 schema=00420936c94ff753 reading=1 := V(billable_work_entry).where(is_billable()).values(duration).sum() +billable_work_entry::billable_count key=b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3 schema=00420936c94ff753 reading=1 := V(billable_work_entry).where(is_billable()).dedup().count() diff --git a/crates/lance-graph-glove-parity/tests/gremlin.rs b/crates/lance-graph-glove-parity/tests/gremlin.rs new file mode 100644 index 000000000..85576efdc --- /dev/null +++ b/crates/lance-graph-glove-parity/tests/gremlin.rs @@ -0,0 +1,504 @@ +//! D-CMH-G1: Gremlin steps as typed building blocks of a class method. +//! +//! A traversal is a slice of typed [`Step`]s over ONE concept's population. +//! [`compose`] turns it into a [`MethodBody`] (conditions and folds in the +//! concept's canonical fields), which then goes through the same +//! `MethodTable::prepare` and Quack lowering as every other method. No step +//! runs at execution time: the steps are gone once the body exists. +//! +//! The anchor is the concept's table: each live row is one vertex, one +//! traverser of bulk 1. That is what makes `count()` and `dedup().count()` +//! the same number HERE, and why `dedup()` after `values(f)` (distinct values, +//! a different multiset) is refused rather than collapsed. A hop needs a +//! relation carrier the glove does not declare, so every `out`/`in`/`both` is +//! refused by name; the bag/set behaviour across hops is the #1311 witness +//! (`crates/lance-graph-quack/tests/gremlin_parity.rs`). +mod common; + +use common::*; +use lance_graph_glove_parity::basis::canonical_fields; +use lance_graph_glove_parity::gremlin::{compose, GremlinRefusal, Pred, Step}; +use lance_graph_glove_parity::method::{ + Cond, EffectClass, Fold, Frontend, Law, MethodBody, MethodKey, MethodRef, MethodSpec, + MethodTable, +}; +use lance_graph_ogar::ogar_vocab::class_ids::{BILLABLE_WORK_ENTRY, DOCUMENT}; +use lance_graph_quack::bind::table; +use lance_graph_quack::{lower, Agg, Query}; +use lance_graph_sap::binder::{CatsBinder, TABLE}; + +const IS_BILLABLE: MethodKey = MethodKey([0x31; 16]); +const BILLABLE_HOURS: MethodKey = MethodKey([0x32; 16]); +const BILLABLE_COUNT: MethodKey = MethodKey([0x33; 16]); +const DEDUP_COUNT: MethodKey = MethodKey([0x34; 16]); + +const V: Step<'static> = Step::V(BILLABLE_WORK_ENTRY); + +fn has(field: &'static str, lit: &'static str) -> Step<'static> { + Step::Has { + field, + pred: Pred::Eq(lit), + } +} + +fn spec<'a>(key: MethodKey, body: MethodBody<'a>) -> MethodSpec<'a> { + MethodSpec { + key, + concept: BILLABLE_WORK_ENTRY, + schema: seal(), + reading: READING, + effect: EffectClass::Pure, + body, + } +} + +/// FAILS IF: a composed traversal does not lower to the program of the same +/// question asked directly in Quack, or the composed methods disagree with +/// the oracle or with each other's frontends. +#[test] +fn a_composed_traversal_is_the_direct_quack_query() { + let is_billable = compose(BILLABLE_WORK_ENTRY, &[V, has("billable", "true")]).unwrap(); + let uses = Step::Where(MethodRef::Label("is_billable")); + let hours = compose( + BILLABLE_WORK_ENTRY, + &[V, uses, Step::Values("duration"), Step::Sum], + ) + .unwrap(); + let count = compose(BILLABLE_WORK_ENTRY, &[V, uses, Step::Count]).unwrap(); + let dedup = compose(BILLABLE_WORK_ENTRY, &[V, uses, Step::Dedup, Step::Count]).unwrap(); + // The bodies, as data: what `compose` produced is plain conditions and + // folds over canonical fields. + assert_eq!( + is_billable.body(), + MethodBody::Select { + filter: &[Cond::Eq { + field: "billable", + literal: "true" + }] + } + ); + assert_eq!( + hours.body(), + MethodBody::Query { + filter: &[Cond::Method(MethodRef::Label("is_billable"))], + folds: &[Fold::Sum("duration")], + law: Law::Values, + } + ); + for n in SIZES { + let batch = batch(n); + let cats = CatsBinder::new(&batch); + let (basis, claims) = (canonical_fields(), with_duration()); + let specs = [ + ("is_billable", spec(IS_BILLABLE, is_billable.body())), + ("billable_hours", spec(BILLABLE_HOURS, hours.body())), + ("billable_count", spec(BILLABLE_COUNT, count.body())), + ("dedup_count", spec(DEDUP_COUNT, dedup.body())), + ]; + let t = MethodTable::prepare(&glove(&basis, &cats, &claims), &specs); + // Direct Quack: the developer's own query against the glove. + let tid = lance_graph_quack::bind::Binder::table(&cats, TABLE).unwrap(); + let hours_col = lance_graph_quack::bind::Binder::field(&cats, tid, "hours_logged") + .unwrap() + .col; + let filter = table(TABLE) + .where_eq("billable", "true") + .count() + .bind(&cats) + .unwrap() + .filter; + let direct = |agg| { + lower(&Query { + filter: filter.clone(), + agg, + }) + .unwrap() + }; + let prepared = |k| t.get(k).unwrap().as_ref().unwrap(); + assert_eq!( + prepared(BILLABLE_HOURS).programs(), + [direct(Agg::SumI32(hours_col))] + ); + assert_eq!(prepared(BILLABLE_COUNT).programs(), [direct(Agg::Count)]); + let (c, s) = count_sum(&batch, |code| code == 1); + for f in [Frontend::Quack, Frontend::R2il] { + assert_eq!( + call(&t, &batch, BILLABLE_HOURS, f).unwrap(), + scalars(&[s]), + "n={n}" + ); + assert_eq!( + call(&t, &batch, BILLABLE_COUNT, f).unwrap(), + scalars(&[c]), + "n={n}" + ); + // On the anchor every row is a distinct vertex: dedup is the + // identity, and the count is the same number. + assert_eq!( + call(&t, &batch, DEDUP_COUNT, f).unwrap(), + scalars(&[c]), + "n={n}" + ); + } + } +} + +/// The multiplicity contract of this slice, stated as refusals: nothing that +/// would change a bag into a set, or read through a relation, is lowered. +#[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"), + ] { + assert_eq!( + refuse(&[V, hop, Step::Count]), + GremlinRefusal::NoRelationCarrier("performed_by".into()) + ); + } + // dedup of values is a distinct-value count: a different multiset. + assert_eq!( + refuse(&[V, Step::Values("duration"), Step::Dedup, Step::Count]), + GremlinRefusal::DedupOfValues + ); + // The anchor must be the concept's population. + assert_eq!( + refuse(&[Step::V(DOCUMENT), Step::Count]), + GremlinRefusal::AnchorMismatch { + step: DOCUMENT, + concept: BILLABLE_WORK_ENTRY + } + ); + assert_eq!(refuse(&[Step::Count]), GremlinRefusal::NoStart); + assert_eq!(refuse(&[V, V, Step::Count]), GremlinRefusal::NoStart); + // A sum of vertices, a stream of values with no fold, a filter on values, + // and anything after the terminal. + assert_eq!(refuse(&[V, Step::Sum]), GremlinRefusal::SumOfVertices); + assert_eq!( + refuse(&[V, Step::Values("duration")]), + GremlinRefusal::NoTerminal + ); + assert_eq!( + refuse(&[ + V, + Step::Values("duration"), + has("billable", "true"), + Step::Count + ]), + GremlinRefusal::FilterAfterValues + ); + assert_eq!( + refuse(&[V, Step::Count, Step::Count]), + GremlinRefusal::AfterTerminal + ); + // Silence twin: the same steps in a lawful order compose. + assert!(compose( + BILLABLE_WORK_ENTRY, + &[V, has("billable", "true"), Step::Dedup, Step::Count] + ) + .is_ok()); + assert!(compose( + BILLABLE_WORK_ENTRY, + &[V, Step::Values("duration"), Step::Sum] + ) + .is_ok()); +} + +/// `values(f).count()` is `COUNT(f)`, which differs from `COUNT(*)` once `f` +/// can be null. The method layer has no field-reading count fold, so it is +/// refused rather than lowered as `count()` (which would read no field and +/// skip the field's claim and nullability checks). +#[test] +fn counting_values_is_refused_not_rewritten() { + assert_eq!( + compose( + BILLABLE_WORK_ENTRY, + &[V, Step::Values("duration"), Step::Count] + ) + .unwrap_err(), + GremlinRefusal::CountOfValues + ); +} + +// ===================================================================== +// The same compositions, loaded as text at run time. +// ===================================================================== + +use lance_graph_glove_parity::decl::{parse_decls, DeclError}; +use lance_graph_glove_parity::method::PrepareError; + +fn seal_hex() -> String { + format!("{:016x}", seal().0) +} + +fn lego_text() -> String { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/lego.methods"); + let text = std::fs::read_to_string(path).expect("fixture"); + assert!( + text.contains(&seal_hex()), + "re-seal the fixture: {}", + seal_hex() + ); + text +} + +fn line(label: &str, key: u8, body: &str) -> String { + format!( + "billable_work_entry::{label} key={} schema={} reading=1 := {body}\n", + format!("{key:02x}").repeat(16), + seal_hex() + ) +} + +/// FAILS IF: the Lego methods loaded from text do not prepare to the same +/// programs as the compiled composition, or disagree with the oracle. +#[test] +fn the_lego_methods_load_from_text_and_match_the_compiled_ones() { + let decls = parse_decls(&lego_text()).expect("parses"); + let is_billable = compose(BILLABLE_WORK_ENTRY, &[V, has("billable", "true")]).unwrap(); + let uses = Step::Where(MethodRef::Label("compiled_is_billable")); + let hours = compose( + BILLABLE_WORK_ENTRY, + &[V, uses, Step::Values("duration"), Step::Sum], + ) + .unwrap(); + for n in SIZES { + let batch = batch(n); + let cats = CatsBinder::new(&batch); + let (basis, claims) = (canonical_fields(), with_duration()); + let compiled = [ + ( + "compiled_is_billable", + spec(IS_BILLABLE, is_billable.body()), + ), + ("compiled_hours", spec(BILLABLE_HOURS, hours.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(); + assert_eq!( + get("billable_hours").programs(), + get("compiled_hours").programs() + ); + let (c, s) = count_sum(&batch, |code| code == 1); + for f in [Frontend::Quack, Frontend::R2il] { + let key = |l| t.resolve(l).unwrap(); + assert_eq!( + call(&t, &batch, key("billable_hours"), f).unwrap(), + scalars(&[s]) + ); + assert_eq!( + call(&t, &batch, key("billable_count"), f).unwrap(), + scalars(&[c]) + ); + } + } +} + +/// FAILS IF: a method loaded later cannot name a predicate loaded earlier or +/// compiled in, or the text outlives nothing it should. +#[test] +fn a_later_declaration_reuses_an_earlier_predicate() { + let mut decls = parse_decls(&lego_text()).unwrap(); + // Written after the build and after the first load; it names the loaded + // `is_billable`, and a second predicate it declares itself. + let later = [ + line( + "is_not_billed", + 0xC1, + "V(billable_work_entry).has(billable, false)", + ), + line( + "unbilled_hours", + 0xC2, + "V(billable_work_entry).where(is_not_billed()).values(duration).sum()", + ), + line( + "billable_known_count", + 0xC3, + "V(billable_work_entry).where(is_billable()).has(billable, neq(false)).count()", + ), + ] + .concat(); + decls.extend(parse_decls(&later).unwrap()); + drop(later); + let batch = batch(4097); + let cats = CatsBinder::new(&batch); + let (basis, claims) = (canonical_fields(), with_duration()); + let t = MethodTable::prepare_with(&glove(&basis, &cats, &claims), &[], &decls); + drop(decls); + let (_, unbilled) = count_sum(&batch, |c| c == 0); + let (billable, _) = count_sum(&batch, |c| c == 1); + for f in [Frontend::Quack, Frontend::R2il] { + let key = |l| t.resolve(l).unwrap(); + assert_eq!( + call(&t, &batch, key("unbilled_hours"), f).unwrap(), + scalars(&[unbilled]) + ); + assert_eq!( + call(&t, &batch, key("billable_known_count"), f).unwrap(), + scalars(&[billable]) + ); + } +} + +/// FAILS IF: loading text can express an effect, or a form outside the +/// slice loads instead of being refused by name. +#[test] +fn effects_and_unsupported_steps_are_refused_at_load() { + let head = "V(billable_work_entry)"; + for step in [ + "addV(x)", + "addE(x)", + "property(x, 1)", + "drop()", + "mergeV(x)", + "mergeE(x)", + "sideEffect(x)", + "aggregate(x)", + "store(x)", + "sack(x)", + "inject(1)", + "io(x)", + "call(x)", + ] { + let name = step.split('(').next().unwrap(); + match parse_decls(&line("m", 0xE0, &format!("{head}.{step}"))) { + Err(DeclError::Effect { line: 1, step: s }) => assert_eq!(s, name), + other => panic!("{step}: {other:?}"), + } + } + for (step, what) in [ + ("groupCount()", "groupCount"), + ("order()", "order"), + ("repeat(out(x))", "repeat"), + ("path()", "path"), + ("select(a)", "select"), + ("as(a)", "as"), + ("has(duration, gt(1))", "gt"), + ("where(not(is_billable()))", "not"), + ("mean()", "mean"), + ("fold()", "fold"), + ] { + match parse_decls(&line("m", 0xE1, &format!("{head}.{step}"))) { + Err(DeclError::Unsupported { line: 1, what: w }) => assert_eq!(w, what, "{step}"), + other => panic!("{step}: {other:?}"), + } + } + // 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}.values(duration).dedup().count()"), + GremlinRefusal::DedupOfValues, + ), + ( + "V(document).count()".to_owned(), + GremlinRefusal::AnchorMismatch { + step: DOCUMENT, + concept: BILLABLE_WORK_ENTRY, + }, + ), + ] { + assert_eq!( + parse_decls(&line("m", 0xE2, &body)), + Err(DeclError::Refused { line: 1, refusal }), + "{body}" + ); + } + // Silence twin: every loaded method is pure. + for d in parse_decls(&lego_text()).unwrap() { + assert_eq!(d.effect(), EffectClass::Pure); + } +} + +/// FAILS IF: references between loaded methods bypass the preparation +/// checks: a cycle, a missing predicate, a reference to a fold. +#[test] +fn references_between_loaded_methods_are_checked() { + let text = [ + line("a", 0xF1, "V(billable_work_entry).where(b())"), + line("b", 0xF2, "V(billable_work_entry).where(a())"), + line("c", 0xF3, "V(billable_work_entry).where(missing()).count()"), + line("d", 0xF4, "V(billable_work_entry).count()"), + line("e", 0xF5, "V(billable_work_entry).where(d()).count()"), + ] + .concat(); + 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); + let refused = |l: &str| t.get(t.resolve(l).unwrap()).unwrap().clone().unwrap_err(); + assert_eq!( + refused("a"), + PrepareError::MethodCycle(MethodKey([0xF1; 16])) + ); + assert_eq!( + refused("c"), + PrepareError::UnknownMethodRef("missing".into()) + ); + assert_eq!( + refused("e"), + PrepareError::MethodRefNotPredicate(MethodKey([0xF4; 16])) + ); + assert!(t.get(t.resolve("d").unwrap()).unwrap().is_ok()); +} + +/// FAILS IF: a label reference reaches a method whose key another method +/// also carries. Both holders of the key are refused; a third method must +/// not inline either one's predicate through its label. +#[test] +fn a_label_reference_to_a_duplicated_key_is_refused() { + let text = [ + line("x", 0xD1, "V(billable_work_entry).has(billable, true)"), + line("y", 0xD1, "V(billable_work_entry).has(billable, false)"), + line("z", 0xD2, "V(billable_work_entry).where(y()).count()"), + ] + .concat(); + 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(MethodKey([0xD2; 16])).unwrap().clone().unwrap_err(), + PrepareError::DuplicateKey(MethodKey([0xD1; 16])) + ); + // Silence twin: a unique key behind the label still inlines. + let ok = [ + line("x", 0xD1, "V(billable_work_entry).has(billable, true)"), + line("z", 0xD2, "V(billable_work_entry).where(x()).count()"), + ] + .concat(); + let decls = parse_decls(&ok).unwrap(); + let t = MethodTable::prepare_with(&glove(&basis, &cats, &claims), &[], &decls); + assert!(t.get(MethodKey([0xD2; 16])).unwrap().is_ok()); +} + +/// FAILS IF: a comma or parenthesis inside a quoted literal is read as +/// syntax. The literal must reach preparation whole. +#[test] +fn quoted_literals_keep_their_commas_and_parentheses() { + let batch = batch(2); + let cats = CatsBinder::new(&batch); + let (basis, claims) = (canonical_fields(), with_duration()); + for lit in ["North, America", "a)b", "(x"] { + let body = format!("V(billable_work_entry).has(billable, \"{lit}\").count()"); + let decls = parse_decls(&line("q", 0xD3, &body)).unwrap_or_else(|e| panic!("{lit}: {e:?}")); + let t = MethodTable::prepare_with(&glove(&basis, &cats, &claims), &[], &decls); + assert_eq!( + t.get(MethodKey([0xD3; 16])).unwrap().clone().unwrap_err(), + PrepareError::UnknownValue { + field: "billable".into(), + literal: lit.into() + }, + "{lit}" + ); + } +}