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:
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:
f {g} xIn 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.
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:
/ 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×.
select … by sym, 1M rowsThe 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 ms | Amber 1M | Amber 10M |
|---|---|---|
select sum px by sym | 32.0 | 370 |
select vwap:wavg[sz;px] by sym | 41.0 | 488 |
select from t where px>105 | 13.2 | 62.6 |
sum px, a reduction | 0.28 | 4.0 |
`px xasc t, a sort | 30.4 | 521 |
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.
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.
\sb / turn the status bar off; back to the plain prompt
\sb / turn it back onThe 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.