Skip to content

Commit bfefae3

Browse files
committed
Add a streaming grounded operations tutorial
1 parent 40a307b commit bfefae3

2 files changed

Lines changed: 154 additions & 1 deletion

File tree

website/.vitepress/config.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export default defineConfig({
2727
{ text: "eDSL", link: "/edsl/overview" },
2828
{ text: "Visual editor", link: "/tools/grapher" },
2929
{ text: "Advanced", link: "/advanced/concurrency" },
30+
{ text: "Experimental", link: "/guide/experimental" },
3031
{ text: "Reference", link: "/reference/packages" },
3132
{ text: "Playground", link: "/playground" },
3233
{ text: "GitHub", link: "https://github.com/MesTTo/MeTTa-TS" },
@@ -37,10 +38,17 @@ export default defineConfig({
3738
items: [
3839
{ text: "Introduction", link: "/guide/introduction" },
3940
{ text: "Getting started", link: "/guide/getting-started" },
40-
{ text: "Experimental features", link: "/guide/experimental" },
4141
{ text: "Playground", link: "/playground" },
4242
],
4343
},
44+
{
45+
text: "Experimental",
46+
collapsed: false,
47+
items: [
48+
{ text: "Overview", link: "/guide/experimental" },
49+
{ text: "Streaming grounded operations", link: "/experimental/streaming-operations" },
50+
],
51+
},
4452
{
4553
text: "Learn MeTTa",
4654
collapsed: false,
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
<!--
2+
SPDX-FileCopyrightText: 2026 MesTTo
3+
SPDX-License-Identifier: MIT
4+
-->
5+
6+
# Streaming grounded operations
7+
8+
::: warning Experimental
9+
This is on the `experimental` channel. Install it with `npm install @metta-ts/hyperon@experimental`. The surface may change before it reaches a stable release. See [Experimental features](/guide/experimental) for the channel.
10+
:::
11+
12+
Let us build a grounded operation that produces results one at a time, and see what that lets you do that a normal one cannot.
13+
14+
## The limit of an eager operation
15+
16+
A normal grounded operation returns an array. The evaluator gets to see the results only after your function has built the whole array and returned it:
17+
18+
```ts
19+
import { MeTTa, ValueAtom } from "@metta-ts/hyperon";
20+
21+
const metta = new MeTTa();
22+
metta.registerOperation("first-three", () => [ValueAtom(0), ValueAtom(1), ValueAtom(2)]);
23+
metta.run("!(first-three)")[0].map(String); // [ '0', '1', '2' ]
24+
```
25+
26+
That is fine for three results. It breaks down the moment the results are expensive or unbounded. Suppose you want an operation that yields the natural numbers. You cannot write it as an array: the array is infinite, so your function never returns and the program hangs. Even a merely large sequence pays for every element up front, whether or not the program uses them.
27+
28+
The eager shape forces the same question every time: how many results should I compute? Streaming removes the question.
29+
30+
## A streaming operation
31+
32+
`registerStreamingOperation` takes a generator instead of an array. You `yield` each answer, and the evaluator pulls them one at a time:
33+
34+
```ts
35+
metta.registerStreamingOperation("naturals", function* () {
36+
for (let n = 0; ; n += 1) yield ValueAtom(n);
37+
});
38+
```
39+
40+
The `for (;;)` loop never ends, and that is now fine, because nothing runs it to completion. The evaluator asks for answers only as a consumer demands them. Wrap the call in `once`, which wants a single answer, and exactly one number is produced:
41+
42+
```ts
43+
metta.run("!(once (naturals))")[0].map(String); // [ '0' ]
44+
```
45+
46+
The before and after are the same idea expressed two ways. The eager version says "here are all my results"; the streaming version says "ask me for the next result". Only the second can describe an endless or costly source.
47+
48+
## Stopping early actually stops the producer
49+
50+
It is worth proving that `once` does not quietly drain the generator behind your back. Count how many times the loop body runs:
51+
52+
```ts
53+
let produced = 0;
54+
metta.registerStreamingOperation("counted", function* () {
55+
for (let n = 0; n < 1000000; n += 1) {
56+
produced += 1;
57+
yield ValueAtom(n);
58+
}
59+
});
60+
61+
metta.run("!(once (counted))")[0].map(String); // [ '0' ]
62+
produced; // 1
63+
```
64+
65+
`produced` is `1`. The evaluator pulled one answer, `once` was satisfied, and it closed the stream. Your generator's `finally` blocks run at that point, so a streaming operation that holds a file handle or a network connection can release it the moment the consumer stops caring. This is the real payoff: work that is never needed is never done.
66+
67+
## Binding the caller's variables
68+
69+
An answer does not have to be a bare atom. Yield an object `{ atom, bindings }`, where `bindings` gives values for the variables that appear in the call's arguments, keyed by name with no `$`. Each answer then carries its own binding, the way a Prolog predicate binds its arguments on each solution:
70+
71+
```ts
72+
metta.registerStreamingOperation("digits-of", function* (args) {
73+
for (const ch of String(args[0]))
74+
yield { atom: args[1]!, bindings: { d: ValueAtom(Number(ch)) } };
75+
});
76+
77+
metta.run("!(digits-of 305 $d)")[0].map(String); // [ '3', '0', '5' ]
78+
```
79+
80+
Here `args[1]` is the atom `$d` the caller passed, and each answer binds `d` to a different digit. The operation is nondeterministic: three answers, three bindings, produced lazily.
81+
82+
## Attaching effects to one answer
83+
84+
An answer can also carry `effects`, applied only when that answer's branch is accepted. An effect adds or removes an atom, or binds a token:
85+
86+
```ts
87+
import { MeTTa, ValueAtom, S, E } from "@metta-ts/hyperon";
88+
89+
metta.registerStreamingOperation("remember", function* (args) {
90+
yield {
91+
atom: args[0]!,
92+
effects: [{ kind: "addAtom", space: S("&self"), atom: E(S("seen"), args[0]!) }],
93+
};
94+
});
95+
96+
metta.run("!(remember thing)"); // yields `thing`, and adds `(seen thing)` to the space
97+
metta.run("!(match &self (seen $x) $x)")[0].map(String); // [ 'thing' ]
98+
```
99+
100+
The `(seen thing)` atom lands in the space exactly when that answer is taken, not before, so a pruned alternative leaves no trace. This is what makes an effect safe inside a nondeterministic search: it is scoped to the branch that produced it.
101+
102+
## Streaming asynchronously
103+
104+
Some producers must wait between answers: a paginated HTTP endpoint, a database cursor, a queue. `registerAsyncStreamingOperation` takes an async generator, and the async runner (`runAsync`) awaits each answer:
105+
106+
```ts
107+
metta.registerAsyncStreamingOperation("issues", async function* (args, signal) {
108+
for (let page = 1; ; page += 1) {
109+
const response = await fetch(`https://api.example.com/issues?page=${page}`, { signal });
110+
const rows: { id: number }[] = await response.json();
111+
if (rows.length === 0) return;
112+
for (const row of rows) yield { atom: args[0]!, bindings: { id: ValueAtom(row.id) } };
113+
}
114+
});
115+
116+
// One page is fetched, one row is used, and the tail never requests page 2:
117+
await metta.runAsync("!(once (issues $id))");
118+
```
119+
120+
The `signal` argument aborts when the evaluation is cancelled, so a `fetch` in flight is cancelled and no further page is requested the instant the consumer stops pulling. Lazy pagination falls out for free: you describe how to get the next page, and the demand decides how many pages you actually fetch.
121+
122+
## Errors and falling through
123+
124+
Errors behave like the eager API:
125+
126+
- Throw before the first answer and the call becomes an `(Error ...)` result.
127+
- Throw `IncorrectArgumentError` to leave the expression unevaluated, so another `=` rule can match it (MeTTa's multiple dispatch).
128+
- Throw partway through, after some answers, and the stream ends with an `(Error ...)` answer following the answers already produced.
129+
130+
```ts
131+
import { IncorrectArgumentError } from "@metta-ts/hyperon";
132+
133+
metta.registerStreamingOperation("only-values", (args) => {
134+
if (args[0]?.metatype() !== "Grounded") throw new IncorrectArgumentError("want a value");
135+
return [args[0]!][Symbol.iterator]();
136+
});
137+
138+
metta.run("!(only-values sym)")[0].map(String); // [ '(only-values sym)' ]: left for another rule
139+
```
140+
141+
## When to reach for streaming
142+
143+
Prefer the eager `registerOperation` when the result is a small, fixed list you will always use in full; it is the simplest thing and there is nothing to gain from laziness. Reach for `registerStreamingOperation` when the source is unbounded, expensive per element, or read behind a consumer such as `once`, `if-decons`, or a `let` that takes the first match. Reach for the async twin when producing the next answer needs to await.
144+
145+
The rule of thumb: if you have ever written an operation and guessed at how many results to return, streaming is the shape that removes the guess.

0 commit comments

Comments
 (0)