Repository navigation
Releases: gburd/improv
Release list
v0.10.0
The formula language gets a function registry of 681 built-in functions
with Excel's and Gnumeric's names (0.9.0 had a few scalars): math, text,
dates, statistics, finance, lookups, arrays, LET and LAMBDA (named
LAMBDAs too), engineering, options, distributions, matrix analysis, and
FIXPOINT for iterative models such as PageRank. Every function has
parameter names, help, autocomplete with ghost arguments in the GUI and
TUI, and located error messages.
pg_improv 0.10.0 keeps large models live. Aggregated bindings (sum,
count, min, max, avg and the new count_distinct) and new items
apply incrementally in logical mode: on 2.5 million rows, a 1,000-row
batch is visible in about 0.1 s with no rebuild. There is a new commit
sync mode; you can redefine and drop measures from SQL; dimensionless
results are supported; and named LAMBDAs are kept in improv.lambda.
New here: a DuckDB extension, improv, which loads into DuckDB 1.5.6 and
2.0; Mentat 1.16.0 from crates.io; Verus-proven integer kernels; and every
release is published to PGXN.
Phases P1–P7 of the built-in functions plan: 674 functions (plus
aliases) with Excel's names, typed errors, the IF family,
positional forms and recurrences, lookups, list results, LET and
LAMBDA, and locale-aware entry.
Added
- Category parameters in named LAMBDAs. A parameter the body uses
where a category goes (OVER c,POSITION(c),RELABEL's categories)
takes a category at the call, soLOGNORM = LAMBDA(x, c, LN1P(x) / LN1P(MAX(x OVER c)))is called asLOGNORM(Authored, Person). A
reduced parameter takes a measure, which keeps the body'sOVERwhen
the call expands. - Named LAMBDAs in pg_improv.
improv.define(model, 'LOGNORM = LAMBDA(…)')stores a named LAMBDA in the new catalog table
improv.lambda(dumped with the model); the model's formulas call it
by name. Redefining one (replace => true) recomputes every measure
that calls it, in the caller's transaction, writing only the rows whose
value changed; it is an error naming the measure when it would break
one or change its type or dimensions.improv.drop_measuredrops one,
and refuses while a measure or another lambda calls it. The GUI's
Postgres store reads and writes the same table (it creates it on an
older catalog). A model it saved with its lambdas inimprov.meta
still loads, and its next save moves them into the table. - Redefine and drop measures from SQL.
improv.define(model, definition, replace => true)redefines a derived measure. Re-running
the same definition changes nothing: nomodel_versionbump, no
result row written. A new formula recomputes the measure and its
dependents in the caller's transaction, in every sync mode, and
writes only the rows whose value changed.improv.drop_measure(model, measure)drops a measure: a derived measure's result table, or an
input's binding and cells. A replacement that changes the value type
a dependent reads, and a drop of a referenced measure, are errors
that name the dependent. - Dimensionless results in pg_improv. A derived measure with no
categories, such asMaxVol = MAX(Vol OVER Person), gets a one-row
result table with just avaluecolumn.recalc, every sync mode,
what_if(itscoordis{}) andstatushandle it;defineno
longer refuses it. - Role-playing categories:
RELABEL(x, From, To)andSWAP(x, A, B).RELABEL(R[Dst, Iter], Dst, Src)ranges overSrc × Iterwith
the same values;W > 0 AND SWAP(W, Src, Dst) > 0is the reciprocal
test (W read with its two roles exchanged), with no@ITEMor
XLOOKUP. Items match by name; one with no namesake is dropped. See
The Formula Language → Roles. COALESCE(a, …, default)andFILL(x, default): a value for a missing
cell.COALESCEhas a cell wherever an argument over all the result's
categories has one, and takes the first argument with a cell there;
FILLhas a cell at every item combination of x's categories. With
Authoredset for 2 of 4 people,COALESCE(Authored, Vol * 0)and
FILL(Authored, 0)both give 4 cells, whereIFNAandIF(ISNUMBER(…))
give 2. Both are an outer union in the dataflow (the arguments' cells,
tagged by position, reduced to the first per coordinate), so input edits
stay incremental;IF's laziness is unchanged. See The Formula
Language → Missing cells.FIXPOINT(init, LAMBDA(r, body), [max_iter=100], [tolerance=1e-10]).
A measure that is the fixed point of a step: r = init, then r = body(r),
until Σ|Δ| ≤ tolerance or max_iter rounds pass. Past max_iter it is the
last iterate, never a hang (max_iter ≤ 10,000), so the language stays
total. The engine solves it round by round, warm-starting from the
previous result after an edit. A round evaluates the step's plan in
memory, with the cells of the measures it reads fixed for the solve
(they still update incrementally on the dataflow); a step the host
evaluator does not cover (PREV, recurrences) is a dataflow round. The
two give the same cells bit for bit. The customer's PageRank (damping
0.85, dangling mass spread uniformly) on 1,500 nodes and 100,000 edges
matches a plain power iteration to 3e-15 in L1, in 14 rounds and 0.44 s
(release), and to the same after one edge weight changes (0.07 s). On
10,000 nodes and 1,000,000 edges it takes 6.9 s, 1.2 s after an edit.
See Fixed points in the formula guide.- Measures reduced together may range over different categories.
They need to share only theOVERcategories. The rest broadcast, and
the result keeps them:SUMPRODUCT(r, M OVER Src)withroverSrc
andMoverSrc × Dstgives one value perDst, andXLOOKUP(k, Key OVER Dst, X)takes anXwith more categories thanKey. These
were "the measures reduced together must range over the same
categories". RANK.DENSE(x OVER T, [order])andCOUNTUNIQUE(x OVER T).
RANK.DENSEequals SQL'sdense_rank() OVER (ORDER BY x DESC), ties
included (order 1 ranks ascending);COUNTUNIQUE(Google Sheets' name)
equalscount(DISTINCT x). The goldens are checked against PostgreSQL's
dense_rank()andcount(DISTINCT …); LibreOffice and Gnumeric have
neither function.- pg_improv: aggregated bindings are incremental in logical mode. A
measure bound withagg(sum,avg,min,max,count,
count_distinct) keeps per-coordinate fold state in the sync worker,
so an INSERT, UPDATE (also one that moves a row to another coordinate)
or DELETE updates its cell without a rebuild; deleting one of two rows
that share acount_distinctkey keeps the count. ATRUNCATEremoves
the relation's cells without a rebuild too. Relations an aggregated
binding reads getREPLICA IDENTITY FULL. A textmin/maxbinding
still rebuilds on change. - pg_improv: new items without a rebuild in logical mode. A row with
a dimension value the model has no item for no longer rebuilds the
model: the worker mints the item throughimprov.mint_itemsin its
own transaction (same ids and order as a load) and feeds the row to the
live engine. It still rebuilds when a formula reads that category's
item list (POSITION,PREV/NEXT, windows,INTO,FILL,RANK,
…; the README lists them).improv_engine::session::Engine::serves
tells whether a grown model still compiles to the engine's graph. - pg_improv:
improv.statuscounts the worker's applies and
rebuilds. Two new columns:incremental_applies(batches applied
to the live engine) andrebuilds(full loads). The 0.9.0 → 0.10.0
update script adds them toimprov.sync_stateand recreates
improv.status. - pg_improv: trigger mode loads only the touched groups. When the
recompute is element-wise, each binding is read under aWHEREon the
statement's touched dimension values, so an aggregated binding loads
only those coordinates'GROUP BYgroups, not the whole fact table.
Commit mode still recomputes the written relations' dependents in full
(the README says why). - pg_improv: distinct counting.
improv.bind_measure(model, measure, rel, key_col, agg => 'count_distinct')counts each coordinate's distinct
key_colvalues, ascount(DISTINCT key_col)does (the result is a
number). Theimprov.binding.aggCHECK accepts it; the 0.9.0 → 0.10.0
update script widens it. DuckDB'sinput X = col count_distincttoo. - A DuckDB extension,
improv.improv_calc(formula, inputs)
evaluates any Improv formula on named inputs from a JSON object
(improv_calc('ROUND(PMT(rate/12, n, -pv), 2)', json_object(...))),
NULL for an error value;improv_calc_textreturns any type and renders
errors (#DIV/0!).improv_functions()lists the 674 built-in
functions with signatures (query :=searches them).improv(source, model)reads a table or query, builds a model from a short spec
(category Time = year,input Quantity = qty sum,Revenue = Price * Quantity) and returns one row per computed cell. Errors name the spec
line. It uses DuckDB's stable C API, so one binary loads into DuckDB
v1.5.6 and v2.0. The DuckDB-free logic is a new crate,
improv_duckdb_model. See Improv in DuckDB. - Formally verified engine kernels. A new crate,
improv_verified,
holds integer kernels with Verus
specifications and machine-checked proofs, and the engine calls them:
GCD/LCM(the result is the greatest common divisor, andLCM's
2⁵³ overflow check is exact), base conversion (DEC2BIN,HEX2DEC,
…,BASE: decoding an encoding gives back the value, every digit is
in range), the 48-bitBIT*operations, the derived-measure build
order (each measure after its dependencies; an error exactly when
there is a dependency cycle),TAKE/DROP's index range, and the
coordinate-key projection and union b...
v0.9.0
pg_improv is ready for real databases: it builds and is tested on
Postgres 15, 16, 17 and 18; it upgrades in place with ALTER EXTENSION;
pg_dump keeps its models; it installs without the preload; and it is
published to PGXN, with binary packages per Postgres major. A worked
family-finance example shows a household's accounts, statement
reconciliation, budgets, net worth and savings goals in one model.
Added
- pg_improv on Postgres 15, 16, 17 and 18 (pgrx features
pg15…pg18;
17 stays the default). CI runs the whole suite on each. - Upgrades:
sql/pg_improv--0.8.0--0.9.0.sqland--0.8.1--0.9.0.sql,
soALTER EXTENSION pg_improv UPDATEmoves a live database forward.
tests/upgrade.shbuilds each earlier release, puts a logical-mode model
on it, upgrades, and checks results, the worker,defineandpg_dump. tests/txn.sh: the transactional guarantees, end to end. In trigger
mode a writer reads its own results, other sessions see them only after
commit, and ROLLBACK, an error or a rolled-back savepoint undo them. In
logical mode aborted work never reaches the results. Also checks a
no-preloadCREATE EXTENSION, that every object is in schemaimprov,
and dump/restore.- PGXN:
crates/pg_improv/META.json.inandci/pgxn-dist.sh, which
builds a buildable source dist (the extension and the six crates it links)
that CI builds on every push and the release uploads withpgxn-release. - Release packages:
pg_improv-<ver>-pg<N>-x86_64-linux.tar.gzfor each
supported major, fromcargo pgrx packageagainst that major's PGDG build. - Release announcements:
ci/announce.shdrafts and submits the
postgresql.org news item (which feeds pgsql-announce) for feature and
major releases. A patch release is skipped unless its CHANGELOG section
has### Security. - CI:
ci/pg_improv.sh <PG>, one script for GitHub, Forgejo and a
local run: fmt, clippy,#[pg_test], pgspot,txn.sh,logical.sh,
and on 17 alsoupgrade.shand the PGXN dist build. - Family-finance example (
crates/pg_improv/examples/family-finance,
guide chapter Improv in Postgres: a family's finances).
Fixed
- pg_improv:
CREATE EXTENSIONwithoutshared_preload_librarieskilled
the session._PG_initdefined the postmaster-level GUC
improv.databaseon every load; Postgres refuses that after startup with
a FATAL. It is now defined only when preloading. - pg_improv:
pg_dumplost every model. Theimprov.*catalog was not
registered as extension configuration, so a dump held the result tables
but restored the models empty. The catalog and its sequence are now
dumped, and a restored database's worker creates a new slot and catches
up instead of trusting the dumped slot position. - pg_improv: binding a view gave an obscure error and left the model half
bound. Logical and trigger sync need a table's row changes, so
bind_measureandset_sync_modenow refuse a view (or materialized or
foreign table) up front with a message saying to bind a table or use manual
mode. Views still bind in manual mode. - pg_improv on PG 18:
HeapTupleHeaderGetRawXminis ambiguous there
(PG 18 exports a C function of that name), which broke the build.
Security
- pg_improv's install and update scripts pass pgspot.
improv.mint_items
pinssearch_path, and the extension creates its objects with plain
CREATEinstead ofIF NOT EXISTS/OR REPLACE, so it cannot adopt an
object another role created first. CI runs pgspot on every change.
v0.8.1
Fixed
- pg_improv: a measure defined after the sync worker caught up is
computed at once. In logical mode the worker rebuilt only when a
decoded transaction touched a bound row, andimprov.define()changes
the model, not the data, so the new result table stayed empty until the
next write to the fact table, with no error inimprov.status. The
worker now checksmodel_versionon every poll.logical.shcovers a
definewith no data change.
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 gri...
v0.5.0
Improv v0.5.0 A cross-platform multidimensional spreadsheet (Lotus Improv / Quantrix lineage) in Rust: differential-dataflow incremental recalculation, embedded (SQLite) Mentat storage, and four front-ends (CLI, TUI, desktop GUI, HTTP server). Highlights: - Engine: DD incremental recalc; Number/Boolean/Text/Date/Enum/Error value lanes; standalone/broadcast literals; aggregation + scalar functions. - Formula language: dimension-aware DSL, date literals, CALL()/SQL() source forms, controlled-natural-language translation. - GUI (egui, NeXTSTEP look-and-feel): margin category tiles, formula bar, tool palette, saved views, filters, charts, multi-category-per-axis stacking, virtualized large-grid rendering. - What-if scenarios (first-class, deterministic input overlays). - External-language functions: Python, R, Julia, WASM (wasmi), Pure-lang. - SQL connectivity: SQLite, PostgreSQL, DuckDB; connection management with out-of-band credentials; live-query refresh with a scheduler daemon. - Server: JSON HTTP API with bearer-token authentication. - Storage: model persists as datoms on embedded Mentat. Quality gate green: fmt, clippy -D warnings, workspace tests, cargo-deny, typos, cargo doc. MSRV rustc 1.97.