|
| 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