AmberPost 06
Building Amber on ngn/k  ·  Part 06 of 07
06

Two point oh: infix dyads, qSQL in every script, and a 17x symbol group-by

The version number crossed a whole integer, and it did it on ergonomics rather than vocabulary. The syntax the docs always promised now parses, and for the first time the numbers are measured against the real thing.

v2.0.0 2026-08-28·14 min read ·Andrea Bonucci

Two point oh is a big number for a release that adds no new verbs. What it closes instead is the oldest kind of gap a language can have: the distance between the syntax you can type and the syntax the documentation told you to type. For a couple of years the docs wrote t lj kt and the parser demanded lj[t;kt]. The docs wrote select … from … where … and a script demanded sel"…". This release makes the page and the parser agree.

It also ships a symbol group-by that got about 17x faster, which turned out to be the more useful thing to know, and the more humbling.

Infix notation for the two-argument library dyads

The join family and the set/search dyads can now be written infix, exactly as in q, instead of only in bracket form. Nothing about the functions changed; only how the parser is willing to read them:

amber>
2 3 9 in 2 3 4          / 1 1 0        (was: in[2 3 9;2 3 4])
5 within 3 9            / 1b           (was: within[5;3 9])
t lj kt                 / left join    (was: lj[t;kt])
`sym xasc t             / sort         (was: xasc[`sym;t])
"/" sv ("a";"b";"c")  / "a/b/c"    (was: sv["/";("a";"b";"c")])
1 2 3 except 2          / 1 3          (was: except[1 2 3;2])

The rule applies to any two-argument function. The join and set/search dyads (in within like lj ij uj aj aj0 wj wj1 pj ej cross inter union except ss sv vs xasc xdesc) and your own functions alike: f:{x+y}; 3 f 4 is 7. The mechanism is not new: ngn/k has always treated every unicode-named identifier as infix. All this release does is extend that same rule to ASCII-named dyads, so the ergonomics of the join family finally match the ergonomics they already had for the symbolic primitives. The one thing held back is an inline lambda literal between two args (see below).

The important word is additive. The bracket form still works unchanged, as do f[x;y] and the one-argument f x, and a name is left an ordinary lvalue when it is being defined or amended, so in:{…} still assigns rather than parsing as an operator. Nothing that worked before breaks; there is one more way to spell what already worked.

Why lambdas are left out

The one thing that is not made infix is a lambda literal, and that is a decision, not an oversight. Consider:

amber>
f {g} x

In Amber's grammar that must keep meaning f({g}[x]): apply the lambda {g} to x, then hand the result to f. But it is lexically identical to the shape noun {lambda} noun, which is what an infix operator between two arguments looks like. q can tell those apart because its parser is type-aware and knows at parse time whether the middle token is a function being used as an operator or a value. Amber's parser is purely syntactic, and a purely syntactic parser cannot make that distinction without guessing. So it does not guess: lambdas stay prefix, and the ambiguity never arises.

Verified against the reference

x in y, x within y, t lj kt and the rest were checked to produce output identical to q and to their own bracket form, across twelve cases in test.k. Two ways to spell one operation have to give one answer, or the syntax sugar is a bug.

Bare qSQL now works inside a loaded .k file

select … by … from … where …, along with exec, update and delete, used to work bare only on the interactive input line. Inside a script you had to wrap the query in sel"…", which meant the first thing anyone learned about Amber at the prompt was the first thing they had to unlearn when they saved their session to a file.

The C loader (bsl, in src/m.c) now runs every file it loads through the same qSQL rewriter the REPL uses, qrwf / qtry, defined in qsql.k. So a .k file loaded once the standard library is up gets the identical bare-qSQL treatment the prompt does:

a loaded .k file
/ after the stdlib is up:
select sum px by sym from trades where px>100      / just works -- no sel"..."

Three properties keep this from being dangerous. The rewrite is line-by-line, which matches the REPL exactly rather than inventing a second dialect for files. It is guarded by an `ERR sentinel, so a rewrite that fails falls back rather than corrupting the script it was handed. And it is gated: nothing changes until qsql.k has defined the rewriter, so the bare interpreter and the bootstrap that runs before qSQL exists are untouched. A script with no qSQL in it is passed through byte-for-byte. Covered by test_qsql.k and tests/test_qsql_script.sh.

Symbol group-by, ~17× faster

Grouping a table by a symbol column used to lexically string-sort the symbols: an O(n·log n) sort with a per-character compare on every step. But equal symbols already have equal interned ids, which is the entire point of interning them, so the string sort was doing expensive work to rediscover something the symbol table already knew. group (src/o.c) now partitions by the interned 4-byte id instead.

The result is byte-identical. Equal symbols map to equal ids, so the partition and the first-appearance key order come out exactly as before, but found without the sort. select … by sym on 1M rows went 587 ms → 34 ms, and the group-by-sum row below moved about 17×.

587→34 ms
select … by sym, 1M rows
~17×
symbol group-by, id-based partition
521 ms
10M table sort, `px xasc t`

The workload is a trade table grouped and filtered by symbol, on identical data from a closed-form generator (no RNG), answers checked equal before anything was timed, minimum of five warm reps, kernel time only:

Workload (by sym), median msAmber 1MAmber 10M
select sum px by sym32.0370
select vwap:wavg[sz;px] by sym41.0488
select from t where px>10513.262.6
sum px, a reduction0.284.0
`px xasc t, a sort30.4521

The shape of the result is worth being honest about. The pure array primitives (sort and reductions) are where Amber's ngn/k core is strongest. The remaining weakness is group-by aggregation: group is now id-based rather than string-sorting, but it is still sort-based, where a hash group with a fused per-group aggregate would be O(n). That is the next kernel to write.

Where the next release goes

A hash-group kernel, replacing the sort-based partition with an open-addressed hash and fusing the per-group reduction into it, is the obvious next optimisation, and it is the one that would close the aggregation gap. Naming the distance is the first step to spending it; the full table renders as charts on the benchmarks page.

Two engine bugs that were quietly wrong

Both of these were tracked in docs/MISSING.md, and both had the property that makes a bug expensive: they produced an answer, so nothing crashed to tell you the answer was wrong.

&() returned the wrong empty

& (where) on a literal empty generic list returned ,!0, a one-element list containing an empty int vector, instead of !0, a plain empty int vector. whr's generic-list branch (src/v.c) ran its nested-grouping expression over a bare empty (), which also carries the generic type `A, instead of recognising "zero elements, nothing to do". It now short-circuits an empty generic list straight to an empty int vector, byte-identical to &!0. That off-by-one-container difference was silently affecting ss and any qSQL path that built a literal empty (). Five cases in test.k now pin it.

An unterminated block comment silently ate the rest of the file

A bare / on its own line opens a block comment that runs until a matching \ line. If that closing line never came, the comment ran to end-of-file and the program exited 0 with no diagnostic, quietly dropping every line after the stray /. The kind of bug that turns a typo into a mystery. It now raises a clean parse error. Properly-closed / … \ blocks and trailing / … line comments are unchanged; covered by tests/test_comments.sh.

The REPL: bracketed paste and a status bar

The native line editor from 1.9.5 (src/ln.c), the one that retired rlwrap, got two additions: passive bracketed paste, and a Claude-Code-style status bar that is now on by default (\sb toggles it off). Every terminal test still passes.

Bracketed paste

The editor now enables bracketed paste (ESC[?2004h). Paste a multi-line script and it runs line by line, exactly as if each line were typed, so inline comments (x:1 / c) and full-line comments (/ c) work, and bare qSQL in a pasted line is rewritten just as it would be at the prompt. The first pasted line submits immediately; the rest are queued and drained under the prompt, one at a time.

Running the paste line by line rather than as one blob is what makes the rest work: a multi-statement string fed to whole-block . text eval raises 'limit, and more importantly, per-line execution is what makes comments and qSQL rewriting behave identically to typing. Paste mode is torn down on every exit path, so a paste interrupted midway never leaves the terminal in a strange state. Driven over a real pty by tests/test_paste.py.

The status bar: \sb

On by default. The editor sets a DECSTBM scroll region and paints a Claude-Code-style bar on the bottom row (version, live memory, execution time, key hints) inside a rounded input box that stays locked while your session scrolls above it. The wheel scrolls back up through the transcript with the box staying put, and now sideways too: output wider than the terminal, a many-column table or a wide chart, clips at the right edge instead of wrapping into a mangled box, and Shift+←/→ or the horizontal wheel pans across it. The whole thing runs in the alternate screen so quitting leaves the terminal exactly as it was. Type \sb to turn it off; \sb again restores it.

amber>
\sb                             / turn the status bar off; back to the plain prompt
\sb                             / turn it back on

The property that matters here is the same one that made the line editor trustworthy in the first place: src/ln.c's atexit hook resets the scroll region on every exit path, whether \\, Ctrl-D, or a crash, so the terminal is never left scrolled-in. A status bar that survives the process that drew it is a bug you feel for the rest of the day, and this one does not.

What 2.0.0 is really about

Every change here is a gap being closed rather than a capability being added. Infix notation closes the gap between the docs and the parser. Bare qSQL in scripts closes the gap between the prompt and the file. The symbol group-by closes a gap between Amber and a sort it never needed to do, and shows exactly where the remaining work lives: grouped aggregation, not the array primitives.

The most useful number a project can publish is the one it is losing by.

Amber is strong on the array primitives and trails on grouped aggregation by a factor that has a name and a fix. That is a far more actionable place to be at 2.0.0 than a page of wins would have been. The engine is at github.com/BonucciAndrea/amber; the Notepad is the real C interpreter compiled to WebAssembly, and every example above runs in it.

Part of Building Amber on ngn/k, a seven-part series on what I added to a K interpreter to turn it into a columnar engine for market data.