MEP-45 research note 08, Dataset pipeline lowering
Author: research pass for MEP-45. Date: 2026-05-22 (GMT+7).
Mochi's headline feature is the query DSL on collections. The same
syntax operates on lists, maps with insertion-ordered iteration, and
streams of records loaded from JSON, YAML, CSV, JSONL, or a load
adapter. This note covers the lowering strategy.
1. Language surface recap
From the language docs and examples/load_with.mochi:
let people = load "people.json"
let adults = from p in people
where p.age >= 18
select p
let by_city = from p in people
group by p.city into g
select { city: g.key, count: count(g) }
let joined = from a in users
join b in orders on a.id == b.user_id
where b.total > 100
select { user: a.name, total: b.total }
Surface clauses supported (per the language surface note):
from x in coll [join y in coll2 on x.k == y.k] ... [where pred] [group by key into g] [order by ... asc|desc] [limit n] [offset n] select exprplusunion,intersect,exceptbetween two queries.
2. Pipeline IR
We lower each query to a directed acyclic graph of operators (MIR pass between type-check and codegen):
PipelineOp =
| Source(coll)
| Where(pred)
| Select(expr)
| Join(other_coll, lhs_key, rhs_key, kind) // inner, left, full
| GroupBy(key_expr)
| Aggregate(name, fn) // count, sum, ...
| Order(key_expr, direction)
| Distinct
| Union(other_pipeline)
| Intersect(other_pipeline)
| Except(other_pipeline)
| Limit(n)
| Offset(n)
| Sink // terminal
Each op records:
- Its input record type and output record type (monomorphised).
- Whether it is blocking (must read its input fully before producing output) or streaming.
- Whether it preserves source order.
Where and Select are streaming. Join, GroupBy, Order,
Distinct, Union, Intersect, Except are blocking. Limit is
streaming with early-exit. Offset is streaming with a skip counter.
3. Operator fusion
Adjacent streaming ops fuse into a single emitted loop body:
from p in people
where p.age >= 18
where p.city == "Hanoi"
select { name: p.name, age: p.age }
Becomes one C loop:
mochi_list__People in_ = people;
mochi_list__Person2 out_ = mochi_list__Person2_new();
for (size_t i_ = 0; i_ < in_.len; ++i_) {
struct pkg_People p = in_.data[i_];
if (!(p.age >= 18)) continue;
if (!(p.city.len == 5 && memcmp(p.city.bytes, "Hanoi", 5) == 0))
continue;
struct pkg_Person2 o;
o.name = p.name;
o.age = p.age;
mochi_list__Person2_push(&out_, o);
}
Predicates and projections at the same fusion boundary share a single iteration variable.
4. Joins
4.1 Inner equi-join
The default. Lowering: build a hash table on the smaller side, scan the larger:
mochi_omap__i64_User idx = mochi_omap__i64_User_new();
for (size_t i = 0; i < users.len; ++i)
mochi_omap__i64_User_set(&idx, users.data[i].id, users.data[i]);
mochi_list__Out out_ = mochi_list__Out_new();
for (size_t j = 0; j < orders.len; ++j) {
struct pkg_Order ord = orders.data[j];
struct pkg_User u;
if (mochi_omap__i64_User_try_get(idx, ord.user_id, &u)) {
if (!(ord.total > 100)) continue;
struct pkg_Out o = { .user = u.name, .total = ord.total };
mochi_list__Out_push(&out_, o);
}
}
The query planner picks the smaller side as the build side by reading
either a size annotation or, by default, treating the right side of
join as the probe side.