Skip to content

Releases: gburd/improv

v0.10.0

Choose a tag to compare

@github-actions github-actions released this 20 Oct 17:10
v0.10.0
6159175

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, so LOGNORM = LAMBDA(x, c, LN1P(x) / LN1P(MAX(x OVER c))) is called as LOGNORM(Authored, Person). A
    reduced parameter takes a measure, which keeps the body's OVER when
    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_measure drops 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 in improv.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: no model_version bump, 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 as MaxVol = MAX(Vol OVER Person), gets a one-row
    result table with just a value column. recalc, every sync mode,
    what_if (its coord is {}) and status handle it; define no
    longer refuses it.
  • Role-playing categories: RELABEL(x, From, To) and SWAP(x, A, B). RELABEL(R[Dst, Iter], Dst, Src) ranges over Src × Iter with
    the same values; W > 0 AND SWAP(W, Src, Dst) > 0 is the reciprocal
    test (W read with its two roles exchanged), with no @ITEM or
    XLOOKUP. Items match by name; one with no namesake is dropped. See
    The Formula Language → Roles.
  • COALESCE(a, …, default) and FILL(x, default): a value for a missing
    cell.
    COALESCE has a cell wherever an argument over all the result's
    categories has one, and takes the first argument with a cell there;
    FILL has a cell at every item combination of x's categories. With
    Authored set for 2 of 4 people, COALESCE(Authored, Vol * 0) and
    FILL(Authored, 0) both give 4 cells, where IFNA and IF(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 the OVER categories. The rest broadcast, and
    the result keeps them: SUMPRODUCT(r, M OVER Src) with r over Src
    and M over Src × Dst gives one value per Dst, and XLOOKUP(k, Key OVER Dst, X) takes an X with more categories than Key. These
    were "the measures reduced together must range over the same
    categories".
  • RANK.DENSE(x OVER T, [order]) and COUNTUNIQUE(x OVER T).
    RANK.DENSE equals SQL's dense_rank() OVER (ORDER BY x DESC), ties
    included (order 1 ranks ascending); COUNTUNIQUE (Google Sheets' name)
    equals count(DISTINCT x). The goldens are checked against PostgreSQL's
    dense_rank() and count(DISTINCT …); LibreOffice and Gnumeric have
    neither function.
  • pg_improv: aggregated bindings are incremental in logical mode. A
    measure bound with agg (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 a count_distinct key keeps the count. A TRUNCATE removes
    the relation's cells without a rebuild too. Relations an aggregated
    binding reads get REPLICA IDENTITY FULL. A text min/max binding
    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 through improv.mint_items in 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.status counts the worker's applies and
    rebuilds.
    Two new columns: incremental_applies (batches applied
    to the live engine) and rebuilds (full loads). The 0.9.0 → 0.10.0
    update script adds them to improv.sync_state and recreates
    improv.status.
  • pg_improv: trigger mode loads only the touched groups. When the
    recompute is element-wise, each binding is read under a WHERE on the
    statement's touched dimension values, so an aggregated binding loads
    only those coordinates' GROUP BY groups, 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_col values, as count(DISTINCT key_col) does (the result is a
    number). The improv.binding.agg CHECK accepts it; the 0.9.0 → 0.10.0
    update script widens it. DuckDB's input X = col count_distinct too.
  • 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_text returns 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, and LCM'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-bit BIT* 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...
Read more

v0.9.0

Choose a tag to compare

@github-actions github-actions released this 16 Oct 17:25
v0.9.0
d8ebc80

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.sql and --0.8.1--0.9.0.sql,
    so ALTER EXTENSION pg_improv UPDATE moves a live database forward.
    tests/upgrade.sh builds each earlier release, puts a logical-mode model
    on it, upgrades, and checks results, the worker, define and pg_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-preload CREATE EXTENSION, that every object is in schema improv,
    and dump/restore.
  • PGXN: crates/pg_improv/META.json.in and ci/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 with pgxn-release.
  • Release packages: pg_improv-<ver>-pg<N>-x86_64-linux.tar.gz for each
    supported major, from cargo pgrx package against that major's PGDG build.
  • Release announcements: ci/announce.sh drafts 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 also upgrade.sh and 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 EXTENSION without shared_preload_libraries killed
    the session.
    _PG_init defined the postmaster-level GUC
    improv.database on every load; Postgres refuses that after startup with
    a FATAL. It is now defined only when preloading.
  • pg_improv: pg_dump lost every model. The improv.* 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_measure and set_sync_mode now 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: HeapTupleHeaderGetRawXmin is 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
    pins search_path, and the extension creates its objects with plain
    CREATE instead of IF NOT EXISTS / OR REPLACE, so it cannot adopt an
    object another role created first. CI runs pgspot on every change.

v0.8.1

Choose a tag to compare

@github-actions github-actions released this 15 Oct 17:43
v0.8.1
04b2324

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, and improv.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 in improv.status. The
    worker now checks model_version on every poll. logical.sh covers a
    define with no data change.

v0.8.0

Choose a tag to compare

@github-actions github-actions released this 15 Oct 15:35
v0.8.0
dddc180

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 for improv-tui, improv-gui and improv-server
    (improv(1) gains stream, 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 under docs/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 TUI R reload 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 = expr lines 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 is NaN; aggregations are
    rejected. Validated at CREATE 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), and improv.recalc() (explicit, in your transaction);
    plus improv.what_if and improv.status. The GUI, TUI, CLI and server
    open postgres://…?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')
    (or avg, min, max, count) folds a fact table's rows per coordinate
    into one input cell, in recalc, trigger and logical mode and in the GUI's
    load; without agg duplicate 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_measure takes the
    measure's type from its column (bool; text/varchar/char;
    date/timestamp/timestamptz; the number types as before) and refuses
    others; define accepts boolean, text and datetime results, and result
    tables get a matching value column (boolean, text, timestamptz).
    Logical mode applies their row changes cell by cell like numbers' (the
    worker reads the slot's text with TimeZone = UTC, DateStyle = ISO).

  • pg_improv Postgres extension, layer L0 (in progress). A pgrx 0.17
    extension (crates/pg_improv, pg17) with an improv.* catalog that is a
    superset of improv_storage_pg's, plus improv.create_model,
    bind_category, bind_measure, define, recalc and drop_model.
    recalc recomputes every derived measure in the caller's transaction and
    writes only the changed rows to improv_<model>.<measure>. The new GUC
    improv.max_cells (default 5M) caps the input size. The Postgres-free
    logic is the new workspace crate improv_pg_model. Install and quick
    start: crates/pg_improv/README.md.

  • pg_improv layer L1: trigger-mode sync.
    improv.set_sync_mode(model, mode) installs (for trigger) or removes
    statement-level AFTER INSERT/UPDATE/DELETE/TRUNCATE triggers with
    transition tables on every bound relation. improv.on_change recomputes,
    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 open postgres://…?model=<name> as well as a Mentat file, through
    the new improv_store::Store seam. The new improv_storage_pg::PgModelStore
    keeps the model in the improv.* catalog: bound input measures read and
    write their fact table, unbound cells live in improv.cell, and
    improv.model_version drives the existing reload-and-retry conflict check.
    scripts/gui-shot.sh takes GUI_SHOT_DB to capture such a model.

  • Coverage gate. scripts/coverage.sh (and a coverage CI 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 its Sum (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-shot job
    (GitHub and Forgejo, rust:1.97-trixie) runs scripts/gui-shot-check.sh,
    which compares the headless 1400×900 capture against the golden
    docs/reviews/golden/gui-1400x900.png and 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 list now prints a views: section.

  • GUI screenshot harness (scripts/gui-shot.sh). Builds the binaries,
    loads a fixed Time×Product×Scenario demo model, runs improv-gui on a
    headless Xvfb at 1400×900 and writes target/gui-shot/gui.png plus
    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::value is a CellValue (was f64), so
    the live engine takes boolean, text and date edits and seeds every typed
    input cell. Engine::set still takes an f64.

  • improv.what_if returns jsonb values (was float8), 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, Mentat master at 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);
    set MENTAT_MMAP_SIZE=0 to turn that off. The old pin 1af15e15 lived on
    an unpushed branch and was not fetchable from a clean clone; the new pin is
    on Mentat's pushed master.

  • TUI: the status box grows to fit its content, so the edit/command prompt
    and messages stay visible when pages:/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 - Worksheet rows 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...

Read more

v0.5.0

Choose a tag to compare

@github-actions github-actions released this 28 Aug 18:07
v0.5.0
2225cc6
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.