08. Dataset pipeline
This note describes how Mochi's query DSL and Datalog rules lower onto Go.
Query DSL
Mochi's query is from x in xs where p select e, optionally with order_by, skip, take, joins, group-by, and aggregations. The clower lowers it to an aotir.QueryExpr that the Go lowerer turns into straight-line code.
Simple filter + map
from x in xs where x > 0 select x * 2
lowers to:
result := []int64{}
for _, x := range xs {
if x > 0 {
result = append(result, x*2)
}
}
No iterator chain, no filter().map().collect(). Straight-line code is what gofmt makes look clean and what go vet reasons about correctly.
Order_by + skip + take
from x in xs order by x.score desc skip 10 take 5
lowers to:
result := []Foo{}
for _, x := range xs {
result = append(result, x)
}
sort.SliceStable(result, func(i, j int) bool {
return result[i].Score > result[j].Score
})
result = result[10:]
if len(result) > 5 {
result = result[:5]
}
sort.SliceStable rather than slices.SortFunc because SliceStable is stable (matters when order_by has ties) and slices.SortFunc is O(n log n) non-stable. Go 1.21 added slices.SortStableFunc but we keep sort.SliceStable to match Go 1.21 floor.
Joins
from x in xs join y in ys on x.id == y.id select { x, y }
For small xs / ys the lowerer emits a nested loop (cross product + filter). For large joins it uses a hash join via mochiruntime/query.HashJoin:
result := []Result{}
hashTable := map[K][]Y{}
for _, y := range ys {
hashTable[y.Id] = append(hashTable[y.Id], y)
}
for _, x := range xs {
for _, y := range hashTable[x.Id] {
result = append(result, Result{X: x, Y: y})
}
}
The lowerer chooses nested-loop vs hash join based on a fixture-driven heuristic (currently: hash join when at least one side is annotated as a "table" in the source); Phase 7.4 documents the heuristic.
Left joins emit a marker found bool inside the inner loop; cross joins skip the on predicate.
Group-by + aggregations
from x in xs group by x.category select { category: x.category, total: sum(x.amount) }
lowers to:
groups := mochiruntime.OMap[string, []Order]{}
for _, x := range xs {
groups.Append(x.Category, x)
}
result := []Result{}
for _, cat := range groups.Keys() {
bucket := groups.Get(cat)
total := int64(0)
for _, o := range bucket {
total += o.Amount
}
result = append(result, Result{Category: cat, Total: total})
}
The OMap (insertion-ordered map) gives deterministic iteration order for the result, matching vm3.
Top-K
from x in xs order by x.score desc take 10
When take is small relative to len(xs), the lowerer chooses a heap-backed top-K via mochiruntime/query.TopK:
result := mochiruntime.TopK(xs, 10, func(a, b Foo) bool {
return a.Score > b.Score
})
TopK uses container/heap with a min-heap of size 10, scanning xs once. O(N log K) versus O(N log N) for a full sort.
The lowerer chooses the heap path when take is known at compile-time and is below a threshold (currently 100, configurable per fixture).
String ops in queries
contains, len, index inside the where or select clause lower to the corresponding mochiruntime/stringz helpers. Phase 7.3 covers the string-op subset of the query DSL.
Arena-allocated query results
For queries that produce a known-size result, the lowerer can pre-allocate the slice:
result := make([]Foo, 0, len(xs)) // arena allocation
This avoids the slice-grow-on-append cost. The arena lowering kicks in when the query has no where clause (so the output size equals the input size). Phase 7.5 wires it.
Datalog
Mochi's Datalog surface is fact parent(alice, bob), rule ancestor(X, Z) := parent(X, Z), rule ancestor(X, Z) := parent(X, Y), ancestor(Y, Z), query ancestor(alice, _). The Go lowerer evaluates the entire program at compile-time via semi-naive fixpoint in transpiler3/go/lower/datalog.go.
fact parent("alice", "bob")
fact parent("bob", "carol")
rule ancestor(X, Z) := parent(X, Z)
rule ancestor(X, Z) := parent(X, Y), ancestor(Y, Z)
query ancestor("alice", _)
evaluates at compile time to [("alice", "bob"), ("alice", "carol")] and lowers to:
ancestorResult := []Tuple{
{V0: "alice", V1: "bob"},
{V0: "alice", V1: "carol"},
}
Compile-time evaluation has two consequences:
- The runtime cost of Datalog is zero. The query result is a frozen literal in the emitted Go.
- Recursive rules with parametric queries (
rule reachable(X) := edge(start, X)wherestartis a runtime variable) are not supported. The clower rejects these with a "non-ground Datalog rule" error.
Phase 8 lands the compile-time evaluator. If future surface requires runtime evaluation, the mochiruntime/datalog package is the home for it.
Aggregation primitives
sum(xs), min(xs), max(xs), count(xs), avg(xs) lower to runtime helpers or direct stdlib calls depending on the element type:
sum(xs: list<int>)→mochiruntime.SumI64(xs)min(xs: list<int>)→slices.Min(xs)max(xs: list<int>)→slices.Max(xs)count(xs)→int64(len(xs))avg(xs: list<int>)→mochiruntime.AvgI64(xs)(returns float64)
The slices stdlib package shipped in Go 1.21, which is our floor.
Cross-target consistency
The query lowering shape (straight-line for loop with append, sort.SliceStable for order_by, hash join for big joins) matches the C target (MEP-45) and Rust target (MEP-53) within the constraints of each target's stdlib. This keeps the cross-target test corpus (which compares stdout byte-equality) clean: the same query produces the same result tuples in the same order across every target.