Repository navigation
v0.8.0
Improv runs inside Postgres, and a formula can be a SQL function. The
pg_improv extension models your own tables and keeps results current
incrementally (at 100k cells, a one-row change is visible about 50 ms after
commit); LANGUAGE improv writes SQL functions in the formula language;
the desktop and terminal apps open a Postgres-hosted model by URL and edit
its rows. The GUI now follows NeXTSTEP Lotus Improv closely, scrolls wide
grids sideways and can revert after a save conflict. A security review's
findings are fixed, and every crate is held at 90% function and branch
coverage.
Added
-
Documentation. A Ways to Use Improv chapter (GUI, TUI, CLI,
streaming, Postgres,LANGUAGE improv, server: what each is for and how
to start), a refreshed introduction, getting-started and running pages,
and man pages forimprov-tui,improv-guiandimprov-server
(improv(1)gainsstream,save-view, the CSV and refresh commands).
The guide and API docs publish to Codeberg Pages and GitHub Pages. -
Demo movies: the README and guide show the GUI, the TUI and Improv in
Postgres in motion, each recorded by a script underdocs/demo/. -
Release binaries for Linux, macOS (arm64 and x86_64) and Windows: the
CLI, TUI, GUI and server, with the man pages. -
GUI: grids scroll sideways. A worksheet wider than its window has a
NeXT horizontal scroller in the bottom strip, right of the row tiles, with
the arrows grouped at its left end (spec §4.4). Before, the far columns
could not be reached. -
Revert to Saved after a save conflict: GUI File ▸ Revert to Saved
and TUIRreload the model from the store (with a fresh save token),
picking up the other writer's changes, so the conflicting edit can be
redone. Undo history is dropped; a failed reload changes nothing. -
LANGUAGE improv: SQL functions written in Improv's formula language
(crates/pg_improv).CREATE FUNCTION margin(price float8, cost float8) RETURNS float8 LANGUAGE improv AS $$ price - cost $$. A trusted language:
arguments are named inputs,Name = exprlines define intermediates, the
last line is the result, and the DSL and controlled English both work.
Numbers, booleans, text and dates/timestamps; NULL in gives NULL out where
the result depends on it; division by zero isNaN; aggregations are
rejected. Validated atCREATE FUNCTION; compiled once per backend
(0.14 µs per call over inline SQL). Evaluation is the new
improv_engine::scalar, which shares the dataflow engine's per-cell
operators. -
pg_improv: Improv models over Postgres data (
crates/pg_improv,
crates/pg_model,crates/storage_pg,crates/store). Bind fact-table
columns to categories and measures, define formulas, read results from
improv_<model>.<measure>tables. Three update paths: logical decoding
(default; a background worker tails a slot and applies each batch with the
slot advance in one transaction), triggers (in the writer's
transaction), andimprov.recalc()(explicit, in your transaction);
plusimprov.what_ifandimprov.status. The GUI, TUI, CLI and server
openpostgres://…?model=<name>. Measured on a c7i.4xlarge at 100k cells:
logical keeps a writer's UPDATE at 8 ms (results ~50 ms later); a trigger
makes them visible at commit for ~1.1 s per write. See
docs/src/postgres.md. -
pg_improv: aggregated bindings.
improv.bind_measure(…, agg => 'sum')
(oravg,min,max,count) folds a fact table's rows per coordinate
into one input cell, inrecalc, trigger and logical mode and in the GUI's
load; withoutaggduplicate rows remain an error. The GUI's store refuses
to save an edit to an aggregated measure (Integrity). -
pg_improv: boolean, text and date measures.
bind_measuretakes the
measure's type from its column (bool;text/varchar/char;
date/timestamp/timestamptz; the number types as before) and refuses
others;defineaccepts boolean, text and datetime results, and result
tables get a matchingvaluecolumn (boolean,text,timestamptz).
Logical mode applies their row changes cell by cell like numbers' (the
worker reads the slot's text withTimeZone = UTC,DateStyle = ISO). -
pg_improvPostgres extension, layer L0 (in progress). A pgrx 0.17
extension (crates/pg_improv, pg17) with animprov.*catalog that is a
superset ofimprov_storage_pg's, plusimprov.create_model,
bind_category,bind_measure,define,recalcanddrop_model.
recalcrecomputes every derived measure in the caller's transaction and
writes only the changed rows toimprov_<model>.<measure>. The new GUC
improv.max_cells(default 5M) caps the input size. The Postgres-free
logic is the new workspace crateimprov_pg_model. Install and quick
start:crates/pg_improv/README.md. -
pg_improvlayer L1: trigger-mode sync.
improv.set_sync_mode(model, mode)installs (fortrigger) or removes
statement-levelAFTER INSERT/UPDATE/DELETE/TRUNCATEtriggers with
transition tables on every bound relation.improv.on_changerecomputes,
inside the writer's transaction, only the derived measures that depend on
the changed inputs. It restricts the recompute to the touched coordinates
when the chain is element-wise. Also new:improv.recalc(model, measures text[])recomputes a measure subset and its dependents, and
improv.what_if(model, edits jsonb)returns the derived cells an in-memory
edit would change, without writing anything. README has the
trigger/logical/recalc trade-off table. -
Postgres-hosted models (pg_improv layer L3). The CLI, TUI, GUI and
server openpostgres://…?model=<name>as well as a Mentat file, through
the newimprov_store::Storeseam. The newimprov_storage_pg::PgModelStore
keeps the model in theimprov.*catalog: bound input measures read and
write their fact table, unbound cells live inimprov.cell, and
improv.model_versiondrives the existing reload-and-retry conflict check.
scripts/gui-shot.shtakesGUI_SHOT_DBto capture such a model. -
Coverage gate.
scripts/coverage.sh(and acoverageCI job on
GitHub and Forgejo) fails when any crate drops below 90% of functions or
branches; the workspace stands at about 96% / 95%. New guide
docs/src/running.md: build, run each front end, security defaults, tests,
coverage. -
TUI: Shift+arrows select a range, drawn highlighted, and the status line
shows itsSum(numeric cells only;Sum —when there are none), as the
GUI does. A plain arrow, a click, or any re-layout collapses the range. -
GUI screenshot regression in CI (review plan D5). A
gui-shotjob
(GitHub and Forgejo,rust:1.97-trixie) runsscripts/gui-shot-check.sh,
which compares the headless 1400×900 capture against the golden
docs/reviews/golden/gui-1400x900.pngand fails above 200 differing pixels
(magick compare -metric AE -fuzz 2%), uploading the diff.--update
refreshes the golden (see CONTRIBUTING.md). -
CLI
save-view.improv save-view <db> <view-id> <name> <measure> [--rows Cat,Cat] [--cols Cat,...]writes a named view the same shape the
GUI saves (stacked rows/cols, remaining categories as pages pinned to their
first item).improv listnow prints aviews:section. -
GUI screenshot harness (
scripts/gui-shot.sh). Builds the binaries,
loads a fixed Time×Product×Scenario demo model, runsimprov-guion a
headless Xvfb at 1400×900 and writestarget/gui-shot/gui.pngplus
compare.png, the capture beside the NeXT Improv reference. Output is
pixel-identical run to run. -
GUI range selection. Shift+arrow (or Shift+hjkl), Shift+click and
dragging across cells select a rectangle; a plain arrow or click collapses
it. The status-bar Sum adds the range's numbers. -
GUI window title. The native title reads
Improv — <file>, with a
trailing•while edits are unsaved (an edited in-memory model, or a
failed autosave). -
GUI transient status. Status-bar messages (save, recalc, undo/redo,
added formula, errors) clear themselves after 5 seconds.
Changed
-
improv_engine::session::Edit::valueis aCellValue(wasf64), so
the live engine takes boolean, text and date edits and seeds every typed
input cell.Engine::setstill takes anf64. -
improv.what_ifreturnsjsonbvalues (wasfloat8), so it reports
boolean, text and date measures too (dates as RFC 3339 strings), and its
edits may set inputs of those types. -
Mentat 0.14 → 1.11.1 (pin
6fc2bc7f, Mentatmasterat v1.11.1).
No Improv API changes were needed. Measured on 100k/200k-cell models
(docs/reviews/2026-10-10-mentat-1.11-upgrade.md): loads, partial loads,
evaluation and edits 9–26% faster, re-import 13–15% faster, first import
~10% slower, store files ~29% larger (new history and value indexes).
Reported RSS is higher because Mentat now memory-maps the store (1 GiB cap);
setMENTAT_MMAP_SIZE=0to turn that off. The old pin1af15e15lived on
an unpushed branch and was not fetchable from a clean clone; the new pin is
on Mentat's pushedmaster. -
TUI: the status box grows to fit its content, so the edit/command prompt
and messages stay visible whenpages:/filters:lines are shown. The
key help wraps to the terminal width, and the prompt sits right under the
title so a short terminal clips the help first. -
TUI: a derived measure's formula shows on a status line in controlled
English (Revenue = Price times Quantity). -
GUI: the model browser now leads with a NeXT-style white list of saved
views (Name - Worksheetrows with a worksheet icon). A single click
selects a view and shows its computed description (measure, categories,
pages); a double-click opens it. Categories and Measures move below as
collapsible sections. -
GUI: NeXT scrollers. The grid, model browser and formula list show an
always-visible 19 px#808080trough with a square#AAAAAAknob,
a 1 px bevel on the knob and NeXT's two arrow buttons grouped at the
bottom of the vertical trough (one line per click). -
GUI filtering moved to the tile menu. The worksheet's Filters
shelf is gone; right-click a category tile for a checkbox per item (uncheck
to hide it from that worksheet) and Show all. A filtered tile reads
Name*. -
GUI grid field fills the table area. The white field reaches the
worksheet's edges instead of stopping at the last cell. -
GUI model browser labels measures. Each measure shows a small gray
inputorformulalabel instead of the·/=prefix. -
GUI first worksheet window. A fresh window opens clear of the
application menu and is sized to its grid (360–900 × 300–700 px). -
GUI grid follows NeXT Improv. Cells are plain white with thin black
rules (no stripes, no per-cell buttons); the selection and its row and
column header items fill#AAAAAA. Input cells carry a faint inset
underline; derived cells showformula: …on hover. Double-click (or
Enter / F2) edits a cell; a single click only selects it. -
GUI category tiles follow NeXT Improv. Tiles are 17 px dark-gray chips
with white names (no grip, no ⏷ button; right-click for
Move to Rows / Columns / Pages). Page tiles sit top-left, each above a
white item field that picks the page (replacing the< item [i/n] >
stepper); column tiles sit top-right and row tiles bottom-left. Gutters size
to their tiles, with no placeholder text, and highlight as drop targets
only during a drag. -
GUI worksheet windows open at 560×420 (was 480×320) so the grid and
formula list fit. -
GUI formulas are edited in place in the formula list. The top formula
bar is gone: the selected row is the editor (Enter commits, errors show
under the row, Esc reverts), and typingName = formulaon the list's
last line adds a derived measure. -
GUI formula list lives in each worksheet window, under the grid, with a
Calcbutton. The operator palette and the "New derived measure" form are
gone. -
GUI model browser and Info panel are NeXT windows. The docked
explorer and inspector side panels are gone, so the dark workspace spans
the full width above the bottom panels. The model
browser (<file> - Model, bottom left; Categories, Measures, Views) has a
close box and View ▸ Model Browser toggles it. The Info panel (top
right, closed at start) opens from Info ▸ Info Panel… and closes from
its close box. A fresh matrix starts right of the application menu. -
GUI matrices are NeXT worksheet windows on a dark workspace. The
scrollable canvas is gone: the area between the panels is a#555555
workspace, and each matrix floats on it as its own window with a black
(key) or dark gray title bar reading<matrix> - Worksheet - <file>
(Untitledwhen in memory), a close box while more than one matrix is
open, and a bottom resize bar. Drag the title bar to move a window; clicking
a window gives its matrix the keyboard. Window positions and sizes are saved
in views as before, so existing views load unchanged. -
GUI application menu replaces the tool palette and tab strip. A
floating NeXTSTEP vertical menu (Info, File, Edit, Format, Worksheet, View,
Graph, Print…, Services, Hide, Quit) with cascading submenus now carries
pivot, undo/redo, chart, CSV, save view, save model, add/remove matrix, and
the Info panel (inspector) toggle. The View submenu lists the saved views
that the top tab strip used to show. New key equivalents: Ctrl/Cmd+S saves
the model, Ctrl/Cmd+H hides and Ctrl/Cmd+Q quits, next to the existing
undo/redo chords. None of them fire while a text field has focus. A
selected row in the explorer and formula lists now shows a white face, so
selection no longer looks the same as hover. -
GUI theme is NeXTSTEP grayscale. The GUI uses NeXT's four grays
(#000000,#555555,#AAAAAA,#FFFFFF, plus a#808080scroller
trough) and nothing else: the selection is filled#AAAAAAinstead of blue,
corners are square, strokes are 1 px, the grid sits on white, and category
tiles are dark gray with white text. Formula errors are marked by a 2 px
underline rather than red, and charts use gray shades. Body text passes WCAG
AA: black on#AAAAAAis 9.0:1, black on white 21:1, white on#555555
7.5:1. -
GUI text is Arimo 12. The GUI bundles Arimo 1.33 Regular and Bold
(OFL-1.1,crates/gui/assets/fonts/), metric-compatible with NeXTSTEP's
Helvetica, as its primary proportional font; egui's fonts stay as glyph
fallback. Body and button text are 12 pt, and headings are 12 pt bold. -
GUI grid right-aligns numbers so their digits line up, as Improv does;
text, booleans and dates stay left-aligned. The cell editor for a numeric
measure is right-aligned to match. -
GUI item headers are plain text in a ruled band. Row and column headers
(Q1,Widget) were beveled button chips. They now draw as plain dark text
with rule lines, as in Improv, so only the category tiles look draggable.
Performance
- pg_improv logical mode applies changes incrementally (G1). The sync
worker keeps each model loaded with a live engine, turns decoded row
changes into input-cell edits, and writes only the result rows that
changed (a one-row UPDATE on a 100k-cell model rewrites two result rows).
It rebuilds on a structural change, TRUNCATE, a new item, or an error. It
also logs a standby snapshot after each batch so polls decode only new
WAL. Commit → result visible at 100k cells (p50): 1161 → 52 ms for one
row, 1552 → 559 ms for a bulk UPDATE. - Store load is no longer one query per cell (P1).
load_model/
load_partialfetch (measure, coord, value) in one query per value
attribute (three in all) instead of a query per cell. On a 100k-cell
model (release CLI, 100×1000 CSV)improv evalwent from 279.9 s to
0.58 s andimprov listfrom 283.2 s to 0.67 s. Date cells now load back
as dates instead of text. - Engine edits cost O(changed measures), not O(model) (P3).
session::Enginenow returns a copy-on-writesession::Snapshot
(Arc<HashMap<MeasureId, Arc<MeasureValues>>>): an edit shares every
unchanged measure with the previous snapshot instead of cloning all
tracked cells. On theincremental_many_editsstress test (release,
5,000 single-cell edits) the time per edit drops from 950 us to 62 us at
10k cells (1,052 to 16,097 edits/s), and from 5,639 us to 71 us at 100k
cells (177 to 14,039 edits/s). It is now almost flat in model size.
Engine::new/set/clear/applyreturnSnapshot, and callers clone
it cheaply.
Fixed
-
Numbers display without binary noise. Every front end showed a value
at fullf64precision, so1173 * 0.05read58.650000000000006. They
now show 15 significant digits (58.65); stored values and exports keep
every bit. -
pg_improv: item ids are minted in one place.
improv.mint_items
(created by both the extension andimprov_storage_pg::CATALOG_SQL) mints
ids for new dimension values, serialized per model, with a unique
(model_id, category_id, name)index.improv.recalc, the trigger and
worker paths and the GUI's store all call it, so concurrent loads agree on
ids and nothing is minted twice. Minting no longer bumpsmodel_version. -
Postgres saves no longer clobber SQL edits to bound fact tables
(storage_pg, design §9.2 G2).save_modelnow writes only what changed
since the handle loaded (or last saved). Each bound cell it changes or
removes is guarded by its loaded value (… WHERE value IS NOT DISTINCT FROM <loaded>), so a cell SQL changed in the meantime fails the whole save
withConflict("reload and retry"). A removed bound cell sets the value
column to NULL and keeps the row. A save deletes only the catalog rows
(categories, items, measures, views,improv.cellcells) it had loaded, so
rows added by another client survive. A save with no changes writes no
cells.PgModelStore::snapshot/set_snapshotcarry the base across
handles, likeloaded_tx; without a snapshot a save writes everything
unchecked, as before. -
pg_improv:recalc,defineandbind_*no longer fail with "SELECT
FOR UPDATE is not allowed in a non-volatile function" when they are the
first statement of a transaction. -
Broadcast binary ops with the larger operand on the left. A formula
such asNet = Price[Time,Product] - Discount[Product](or*,+,/)
evaluated to no cells. Only the mirroredDiscount - Priceworked. The
dataflowMapBinaryjoined its operands on the full coordinate, so a row
matched only when the right operand already spanned the result's
dimensions.Join,MapBinaryand two-argument scalar calls now share one
broadcast-join helper that keys both sides on their shared categories. -
Incremental recalculation no longer drops cells whose value went down.
The engine's update stream orders a changed cell's+newbefore-old
when the new value sorts lower, and the session snapshot applied the
retraction by deleting the key, which wiped the value just written. So
Engine::setlost, for example,Revenuewhen aPricedecreased. A
retraction now removes a key only if it still holds the retracted value. -
Sandboxed external functions on non-FHS hosts (NixOS). The interpreter
is now run by its canonical host path, and/nix/storeis bound read-only
when present. Before, every sandboxed call failed on NixOS with
execvp python3: No such file or directory. -
A save no longer clobbers another process's writes.
save_modelretracts
stored cells the model lacks, so when two processes shared a store (improv setbeside a running GUI or server) the second saver deleted the first's
new cells. A save now checks the store's transaction high-water against the
one it loaded at and, if another writer committed since, fails with
StoreError::Conflictand writes nothing. The GUI and TUI report "reload and
retry"; the server answers409 Conflictand drops its cached model so a
retry reloads. NewModelStore::{tx_high_water, loaded_tx, set_loaded_tx, reload_model}. -
Storage: a measure's category order survives save/load.
:measure/categories
is a set, so a reload returned the categories sorted by id, and anything
mapped by position (SQL/CSV columns, coordinates) landed on swapped
categories:refresh-sqlon a 2-dimension measure wrote
{Year=A,Region=2025}. The order is now stored in a new
:measure/category-orderattribute. Stores written before it load as
before (id order) and gain it on their next save; no migration needed. -
TUI: the grid scrolls to keep the cursor visible on models larger than
the terminal (previously the cursor could move off-screen). -
TUI: mouse clicks select the cell under the pointer (they were offset by
the row-label column and selected the column to the right). -
TUI: text and boolean input cells can be edited (parsed per the measure's
type, asimprov setdoes); previously every edit had to be a number. -
TUI: editing is refused when an axis is empty or fully filtered. It used
to store a cell with a missing dimension. -
TUI:
[/]page correctly when an empty category precedes another
page dimension. -
CLI
--ataccepts every natural spelling of a coordinate.setand
scenarionow take repeated flags (--at Time=Q1 --at Product=Widget) and
space-separated pairs (--at Time=Q1 Product=Widget) as well as the
comma-joined form, merging them. Naming a category twice is a clear error,
and stray non-pair arguments are still rejected. -
GUI tool-palette icons no longer render as tofu boxes. Undo, redo,
import/export and save-view used glyphs (↶ ↷ ⇄ ▤) missing from egui's
bundled fonts; they now use glyphs those fonts contain, and a unit test
checks every palette glyph against the loaded fonts. -
The matrix no longer draws over the Inspector. Gutter tiles and the page
well escaped the canvas clip (egui's nested panels reset the clip rect) and
painted on top of the side panels in small windows. They are now clipped to
the canvas. The window also opens at 1280×800 (was eframe's 800×600) with a
960×600 minimum. -
The GUI no longer loses in-memory work silently. File ▸ Save (Ctrl+S)
is disabled for an in-memory model instead of reporting "saved model", and
quitting an edited in-memory model warns first; a second Quit discards it. -
GUI menus stay on screen. A submenu that would run off the right edge
cascades to the menu's left instead and is lifted to keep its bottom on
screen, and the menu now opens on the workspace rather than over the formula
bar's label. -
Re-saving a model no longer piles up cell datoms (P2).
save_model
diffs cells against the store: an unchanged cell is not re-asserted, a
value left under another type's attribute is retracted, and a cell removed
from the model (cleared in the GUI, deleted through the server) is
retracted instead of lingering on disk and coming back on the next load.
An unchanged re-save of a 100k-cell model now adds no datoms (it added 200
and grew the file ~30 KB) and takes 1.3 s instead of 321 s. -
improv initno longer wipes an existing store. It installs the
schema only; it used to save an empty model, which under the cell diff
above would have retracted every stored cell.
Security
- External functions: the host picks the sandbox. Every body runs
Restrictedunless the operator setsIMPROV_EXTFN_TRUSTED=1; a model's
pure: falseno longer turns the sandbox off (S2). - External functions fail closed. Without
bwrap(bubblewrap) a
Restrictedbody is refused with "bwrap not found; install bubblewrap or
set IMPROV_EXTFN_ALLOW_UNSANDBOXED=1"; that variable falls back to rlimits
only. A failedsetrlimitaborts the spawn (S4, S18). - External functions: tighter bwrap. Only
/usr,/bin,/lib,
/lib64and/etcare bound read-only;/tmp,/homeand/rootare
empty tmpfs; all namespaces are unshared (no network). The child
environment is cleared toPATH=/usr/bin:/bin,HOME=/tmpandLANG, so
API tokens and cloud credentials no longer leak (S5, S6). - External functions: bounded output. stdout and stderr are drained
concurrently and capped at 1 MiB each; output over 64 KiB no longer
deadlocks into a timeout (S7). - External functions: the timeout kills the process group, so forked
grandchildren die with the body (S8). - Wasm functions: a fuel budget stops runaway loops (the worker thread
ends), linear memory is capped at 64 MiB, and bodies must start with
wat:orbase64:(a bare path is no longer read from disk) (S9). - Python functions: the body is passed verbatim as JSON data instead of
inside an escaped raw string, so backslashes keep their meaning (S20). - Server: the refresh scheduler is off by default (
IMPROV_SCHEDULER=1
enables it) and runs only external functions listed by digest in the
IMPROV_EXTFN_ALLOWfile, always in theRestrictedsandbox. Serving a
shared model no longer runs its code (S1). - Server: request limits. 10 s header-read timeout (slow-loris), 30 s
request timeout, 64 requests in flight, 1 MiB bodies, 10,000 edits per
/eval(S10, S13). - Server: write validation. Writes need an existing input measure, a
coordinate covering exactly its categories, and finite values. Writes are
serialized, so concurrent ones no longer lose updates. New
IMPROV_API_TOKENS_ROread-only tokens (S12). - Server: internal errors return
{"error":"internal error","request_id":N}
and log the detail, instead of echoing store paths (S14). - Server: auth hardening. Constant-time token comparison; open mode on a
non-loopback address needsIMPROV_ALLOW_OPEN=1(S15). - Server: model cache. Requests share one cached model, reloaded only
after a write, instead of loading the store up to three times per request
(P4). - SQL refresh is read-only. A stored
SqlSource.querymust be a single
SELECT/WITHstatement (check_read_only_sql; stacked statements are
refused), and every backend executes it read-only: Postgres in a
READ ONLYtransaction, DuckDB in aREAD ONLYtransaction with
enable_external_access = false(noread_csv('/etc/passwd'), URLs or
ATTACH), SQLite only ifsqlite3_stmt_readonly. A data-modifying CTE
(WITH x AS (DELETE ...) SELECT ...) is refused by the server. - Postgres fails closed on cleartext: this build has no TLS, so
connect_postgresrefuses a non-loopback host unless the connection
string sayssslmode=disableexplicitly, and refusessslmode=require
(it cannot be honoured). Use an SSH tunnel to a loopback port. - Postgres
NUMERICcolumns now import as numbers (they failed to decode). - CSV export neutralizes formula injection. Text cells, item names and
headers starting with=,+,-,@, tab or CR are written with a
leading', so Excel/LibreOffice show them as text instead of running
them (S16). Numbers are untouched.ExportSpec { neutralize_formulas: false, .. }withexport_measure_csv_withturns it off for an exact
round trip. - CSV import is size-capped. A field over 1 MiB, a record over 64 MiB
(checked while streaming, so an unterminated quote can't buffer the
file) or more thanImportSpec::max_recordsrecords (default
10,000,000) is an error instead of an out-of-memory (S17). - CNL parser depth bound (S3). The
nl_formularecursive-descent parser
had no nesting limit: one ~200 KBPOST /nl/parseof nested parens
overflowed the stack and abortedimprov-server. Paren nesting plus
operator count is now capped at 200 (as in the formula parser), returning
the newNlError::TooDeep; tested on a 2 MiB stack with 100k parens.