# Changelog

Version-by-version feature history for MoonFrame, newest first. The API
concepts and compatibility model live in [`api.md`](api.md), and the per-symbol
reference is generated from the docstrings on
[mooncakes.io](https://mooncakes.io/docs/ihb2032/MoonFrame); the source-level
breaking-change steps for each release are collected in
[`migration.md`](migration.md). Pre-1.0, breaking changes ride the minor
version — including a new variant on a public `pub(all)` enum, which is
source-breaking under MoonBit's exhaustive `match` even though it reads as
additive.

## v0.6.0 — API convergence

The API-convergence release. MoonBit 0.10.4's `fn Type::Type(...)` custom
constructors, optional parameters with defaults, and `internal` packages let a
tail of parallel spellings collapse into one entry each: where two ways of
building or configuring the same value existed, this release keeps one. The
source-level upgrade steps are collected in [`migration.md`](migration.md).

### Features

- **Predicate push-down into the file sources.** A filter sitting on a
  `scan_csv` / `scan_ndjson` leaf is now absorbed into the scan: the reader
  builds the predicate's columns, asks which rows survive, and parses the
  remaining columns for those rows alone. Dtype inference still walks the whole
  file, so dtypes and values match an eager read-then-filter cell for cell,
  and the pruning columns no longer have to ride in the projection — a
  `filter(col("qty") > 50).select([col("region")])` scan now reads `region`
  out and `qty` only internally. Plans render it as
  `SCAN_CSV "f.csv" [region] WHERE (col(qty) > 50)`.
  - Consequence, and the mirror of what projection push-down already
    documents: a `ParseError` confined to a row the predicate drops (in a
    column the predicate does not read) no longer surfaces. A row the
    predicate keeps still reports it.
  - Only the first predicate is absorbed; a second filter stays a node above
    the scan, since combining them would reorder which operand's evaluation
    error surfaces first.
  - A column-less predicate (a literal like `lit_bool(true)`, a no-input
    `map`) is absorbed too: the reader prunes against a key frame with no
    columns, which carries the file's row count as an `N×0` frame, so the
    literal broadcasts over the real height and `filter(lit_bool(false))`
    stops parsing cells entirely.
  - Streaming the file is still future work — the reader tokenises the whole
    file, and the saving is the typed build of the dropped rows.

- **The numeric expression family.** `Expr` gains the arithmetic verbs Polars
  spells the same way: `abs` / `floor` / `ceil` / `sign` unary, and
  `floor_div` / `modulo` / `pow` binary. `floor_div` and `modulo` are named methods rather
  than operators: MoonBit reads `//` as a line comment, and no `Expr` operator
  maps to `%`. Dtype follows the rule `+` already set — same-dtype `Int`
  stays `Int`, any `Float` operand promotes — with two exceptions worth
  stating: `pow` is always `Float`, and `floor_div` / `modulo` by an `Int` zero
  yield a **null** cell rather than trapping, since integers have no infinity
  (their `Float` forms follow IEEE 754 to `±inf` / `NaN` as `/` does).
  `floor` / `ceil` leave an `Int` unchanged, `NaN` and `±inf` pass through, a
  null cell stays null, and a non-numeric operand raises `TypeMismatch`.
- **`is_in`, `is_between` and `round`.** `Expr::is_in(members)` is a `Bool`
  column, true where the cell equals one of the literal `members`: an OR of
  `eq` over the set, which a `String` / `Bool` / `Int` column whose members
  share its dtype takes as an equivalent single-pass membership test instead.
  A `Null` member matches nothing, an empty set is `false` for every present
  cell, a null cell yields null, and a member whose dtype cannot compare with
  the column raises `TypeMismatch`.
  `Expr::is_between(lower, upper, closed? = Both)` carries Polars'
  `ClosedInterval` (`Both` / `Left` / `Right` / `None`), and
  `Expr::round(decimals? = 0)` rounds to a fixed number of places. Both render
  in `explain` as the argument that builds them
  (`col(n).is_between(2, 3, closed=Left)`, `col(f).round(decimals=2)`). A
  negative `decimals` clamps to `0`, and rounding stays total at the numeric
  edges: non-finite values and any scaling that would overflow pass through
  unchanged.
- **Nine more string ops.** `str_reverse`, the padding trio
  `str_pad_start(width, fill? = ' ')` / `str_pad_end` / `str_zfill(width)`,
  `str_slice(offset, length?)`, `str_split_get(sep, index)`, `str_len_bytes`,
  and the regex pair `str_extract(pattern, group? = 0)` /
  `str_count_matches(pattern)`. `str_zfill` is the sign-aware pad: a leading
  `+` / `-` keeps its place and the zeros go after it, so `"-5"` to width 4 is
  `"-005"`. `str_slice` counts characters from a 0-based `offset` that may be
  negative to count from the end, and clamps both bounds to the cell, so it
  never raises on a value; `str_split_get` is a scalar slice of Polars'
  `str.split`, which returns a list this repo has no dtype for, and yields
  null past the last field. `str_len_bytes` counts UTF-8 bytes where
  `str_len_chars` counts characters. Widths and offsets are character-based
  throughout, so a surrogate pair is never split; a null cell stays null, and
  a non-String operand raises `TypeMismatch`.
- **`Expr::map_batches(label~, returns_scalar? = false, f)`** — the batched
  escape hatch. Where `map_elements` hands `f` one `Scalar` per row,
  `map_batches` hands it the whole evaluated `Series` and takes a `Series`
  back, so a vectorized kernel (a cumulative sum, a rank, a rolling window)
  runs once over the column. The result rides `lit_series`' length contract: a
  frame-tall result passes through, a length-1 result broadcasts, any other
  length raises `LengthMismatch`. Set `returns_scalar` when `f` reduces to a
  length-1 series, and the node counts as a per-group reduction — accepted as
  a custom aggregation inside `group_by(...).agg([...])`, where `f` receives
  each group's rows. `label` names the step in `explain`; the closure itself
  is opaque to introspection and to the optimizer, which treats the node as a
  barrier no filter sinks across.
- **Column selectors.** Six helpers read a frame's schema and return the
  `Array[Expr]` of `col(name)` references for the columns they match, in
  schema order, to fill any verb that takes an expression list:
  `numeric_cols(df)` (Polars' `cs.numeric()`), `cols_of_dtype(df, dtype)`,
  `cols_matching(df, pattern)` — a POSIX regex over the column *name* — and
  the literal-name tests `cols_starts_with` / `cols_ends_with` /
  `cols_contains`. The result is an ordinary array, so it composes with
  hand-written entries: `df.select([col("id"), ..numeric_cols(df)])`,
  `df.drop(cols_matching(df, "_tmp$"))`. Expansion is eager — a selector reads
  `df.schema()`, so the frame appears twice at the call site, and a
  `LazyFrame` has no schema to read until `collect`. `cols_matching` raises
  `InvalidOperation` on an invalid pattern; the other five are total.
- **`rename_with(f)`** renames every column through a callback, on both
  surfaces (`LazyFrame` defers it), for when a rule — a prefix to strip, a
  case to fold — describes the whole schema better than an explicit
  `old -> new` list. Column order, dtypes, the row count and each field's
  declared `nullable` are unchanged; the identity `name => name` is a no-op.
  There is no `ColumnNotFound`, since nothing is looked up by name — the one
  failure is a collision, two columns `f` maps to the same name, which raises
  `DuplicateColumn`.
- `DataFrame::reverse()` and
  `DataFrame::with_row_index(name? = "index", offset? = 0)` land on both
  surfaces (`LazyFrame` defers each through its own plan node, rendered as
  `REVERSE` / `WITH_ROW_INDEX "index"`). The counter is dense, never null, and
  sits ahead of the frame's own columns as in Polars.
- `Series` gains the ordering verbs its `DataFrame` twin already had:
  `sort(order?, nulls?)`, `head(n)`, `tail(n)`, and `reverse()`. `sort` runs
  the *same* per-column kernel `DataFrame::sort` uses — moved into `series` for
  this — so one column's ordering means the same thing at either level,
  including `NaN`-counts-as-missing and stability.
- **`Series` statistics.** `std()` and `variance()` are the sample (`ddof = 1`)
  forms Polars defaults to, computed by Welford's algorithm so a
  finite-variance window of near-`Double`-max values still lands finite;
  `median()` is the middle of the sorted non-null values, or the mean of the
  two middles for an even count. All three are numeric-only and widen `Int` to
  `Double`. They split on `NaN` by the rule each belongs to: a present `NaN`
  propagates through `std` / `variance`, as it does through `sum` / `mean`, and
  is skipped by `median`, the order-statistic rule `sort` / `min` / `max`
  follow. Fewer than two non-null cells raises `InvalidOperation` for `std` /
  `variance` — the sample denominator `cnt - 1` has no value — as does an
  empty, all-null or all-`NaN` series for `median`. `first()` and `last()`
  complete the set on the other side of that split: positional, so they skip
  nothing and return a present `NaN` verbatim, and total — an empty series, or
  a null cell in the position asked for, yields `Scalar::Null`.
- **Whole-frame reductions on `LazyFrame`.** `sum` / `mean` / `min` / `max` /
  `count` / `null_count` defer their eager twins, each collapsing the plan to
  the 1-row frame `collect` would produce from the eager call.

### Breaking

- **`Expr` is opaque; its AST is module-internal.** The expression tree used to
  be a `pub enum Expr` a downstream package could pattern-match. It is now an
  opaque `struct`, and the AST it wraps — `ExprNode` and the `BinOp` / `UnOp` /
  `AggOp` / `StrOp` tags — lives in a new `internal/ir` package that no
  downstream module can import. Building expressions is unchanged (`col` /
  `lit_*` / the operators and methods); what is gone is matching an `Expr`'s
  variants from outside `MoonFrame`, which was never part of the intended
  surface. `Expr::to_string` renders one for inspection. The upshot is that the
  AST can grow a node with any new operator without breaking a caller — the
  compatibility promise no longer has an expression-AST exception.
  - `ClosedInterval` (the `is_between` `closed?` argument, new in this release)
    lives in `types` rather than `expr`, since the AST — now below `expr` —
    carries it. Through the facade that is `@moonframe.ClosedInterval`; a direct
    sub-package import spells it `@types.ClosedInterval`. No v0.5 caller has
    anything to move: the same reasoning relocated `SortOrder` / `NullOrder`,
    but those did exist before.
- **`Expr` and `JoinOptions` lose `==`.** Making the AST module-internal closed
  the *visibility* half of that promise, and equality was the half left open:
  comparing two expressions compared their trees, so how an operator lowers was
  observable from outside, and a normalisation or a merged node kind would have
  changed what compared equal. The impl is gone rather than documented, and
  `JoinOptions` — whose key lists are expressions — goes with it. Compare
  `Expr::to_string()` instead: `(col("a") + lit_int(1)).to_string()` is
  `"(col(a) + 1)"`, which is also what `explain()` prints. For join options, compare
  the parts that mean something — `how` / `suffix` / `coalesce` and the
  rendered `on_keys()` / `left_keys()` / `right_keys()`.

- **A column-less frame carries its row count (`N × 0`).** A frame with no
  columns was pinned to `0×0` by INV7, so every projection to zero columns
  silently dropped the height. It no longer does, and the operations that
  reach one keep their rows:
  - `select([])` is `nrows() × 0`, and so is a `drop` of every column.
  - `from_rows(schema, rows)` is always `rows.length()` tall — under an empty
    schema, `[[], []]` is `2×0` rather than `0×0`. This makes
    `from_rows(df.schema(), df.rows())` the identity for every frame.
  - JSON records with no fields keep their count: `[{}, {}]` reads as `2×0`,
    as do NDJSON lines of `{}`.
  - CSV has no field-less row — it would be a blank line, which the reader
    skips — so `format_csv` / `write_csv` refuse an `N×0` frame with
    `InvalidOperation` instead of writing blank lines that read back as `0×0`.
    The `0×0` frame still writes; only the shape that would lose rows is
    rejected.
  - The table renderers are the other place the shape cannot go: `to_markdown`
    / `to_html` draw cells, and an `N×0` frame has none, so both return the
    empty string at any height. `shape()` is what reports a column-less
    height.
  - A file read whose projection matches no header yields the file's rows and
    no columns, instead of falling back to materialising every column to keep
    the row count — a mistyped column name no longer costs a full read.
  - A join takes its height from the row plan, so a cross join of two
    column-less frames is their product: `2×0` cross `3×0` is `6×0`.
  - `agg` takes its height from the group count, so `group_by([]).agg([])`
    over a non-empty frame is the `1×0` grand-total group.
  - The whole-frame summaries are one row for every source frame, so
    `sum` / `mean` / `min` / `max` / `count` / `null_count` over a 0-column
    frame are the `1×0` summary row rather than `0×0`. (`describe` is
    unchanged at `0×8` — it is one row per source *column*.)

  `DataFrame::DataFrame([])` is still `0×0`: it infers the height from the
  columns, and there are none. `is_empty()` remains `nrows() == 0`, so an
  `N×0` frame is not empty, and INV7 now states `nrows >= 0` — the property
  that survives. A `with_columns` over an `N×0` frame computes over its rows.
- **Structured error detail on `TypeMismatch` / `ParseError`.** In v0.5 these
  two `DataError` variants each carried a flat `String`. They now carry typed
  detail enums — `TypeMismatch(TypeMismatchDetail)` and
  `ParseError(ParseErrorDetail)` — so a handler can inspect a type mismatch
  (expected / actual dtype, and the column when one is known) or a parse
  failure (the failing cell's location, column, 1-based position, expected
  dtype, and raw value) structurally rather than scraping a message string.
  `DataError::message()` still renders the same human-readable text, so code
  that only formats the error is unaffected; code that matched
  `TypeMismatch(s)` / `ParseError(s)` for the string now matches the detail
  enum (or calls `message()`).
- `Field::new(name, dtype)` and `Field::with_nullable(name, dtype, nullable)`
  are replaced by the single custom constructor
  `Field::Field(name, dtype, nullable? = true)`.
- `struct Field` is `pub` rather than `pub(all)`, so it can no longer be built
  from a record literal outside `types`; construction goes through
  `Field::Field(...)`. Its fields went private later in this same release (see
  below), read through the `name()` / `dtype()` / `nullable()` accessors.
- The IO options types follow the same shape. `CsvReadOptions::default()`,
  `CsvWriteOptions::default()`, and `JsonReadOptions::default()` are replaced by
  the all-defaulted constructors `CsvReadOptions::CsvReadOptions(...)`, `CsvWriteOptions::CsvWriteOptions(...)`,
  and `JsonReadOptions::JsonReadOptions(...)`, and the three types are `pub` rather than
  `pub(all)` — name only the fields that differ instead of spelling out a
  record literal. Their fields stay public — bar `CsvReadOptions.null_values`,
  private behind a copying accessor (below) — because reading them is what an
  options record is for; that keeps the *field set* part of the compatibility
  surface, since a public field can be destructured (see `api.md`, "API
  stability & compatibility").
- `NdjsonReadOptions` is gone: `read_ndjson_with_options`, `parse_ndjson_str`,
  `read_ndjson_projected`, and `scan_ndjson_with_options` take
  `JsonReadOptions`. (The `*_with_options` names here are the v0.5 spellings —
  the entry-point fold below retires them in this same release.) The two types
  were structurally identical; a
  format-specific field can be added back when one exists, riding the minor
  version as any public-field addition does.
- The two loose reader/writer flags moved into the options they configure:
  `strict_quotes` is now `CsvReadOptions.strict_quotes` (so the lazy
  `scan_csv_with_options` gets it too, which the parameter form never offered)
  and `sanitize_formulas` is now `CsvWriteOptions.sanitize_formulas`.
  The v0.5 `parse_csv_str`, `read_csv_with_options`, `read_csv_projected`,
  `format_csv`, and `write_csv_with_options` lose their trailing optional
  parameter (and the `*_with_options` names themselves are folded away below).
- Every `*_with_options` entry point is folded into its plain form as a
  defaulted optional parameter: `read_csv(path, options?)`,
  `write_csv(path, df, options?)`, `read_json(path, options?)`,
  `read_ndjson(path, options?)`, `scan_csv(path, options?)`, and
  `scan_ndjson(path, options?)` replace the six `*_with_options` twins, and the
  string-level `parse_csv_str` / `parse_json_str` / `parse_ndjson_str` /
  `format_csv` take their options the same way. A call that passed options
  positionally now names them (`parse_csv_str(text, options=opts)`); a call that
  passed the all-defaults options can drop the argument.
- The remaining builder chains collapse into their constructors. `HtmlOptions`
  loses `default()` and its four `with_*` setters for
  `HtmlOptions::HtmlOptions(max_rows? , table_class? , caption? , escape? = true)`;
  `JoinOptions::on` / `left_on` / `cross` take `how` / `suffix` / `coalesce`
  (and, for `left_on`, a required `right_on~`) directly, retiring all six
  `with_*` methods — v0.5.8's five, plus a `with_coalesce_auto` that was added
  and withdrawn before this release cut, so no release ever carried it; and
  `ChartSpec::bar` / `line` / `point` / `area` take `color` / `color_type` /
  `title`, retiring their three `with_*` methods. Thirteen builder methods
  become zero.
- `DataFrame::to_html_with_options(options)` and
  `DataFrame::to_markdown_with_limit(limit)` fold into
  `to_html(options? : HtmlOptions)` and `to_markdown(max_rows? : Int)`.
- `JoinOptions::left_on` now requires `right_on~` at the call site, so an
  unpaired sided join no longer compiles, and mixing `on` with
  `left_on` / `right_on` is unspellable: the three constructors each fill one
  key shape, and the three key lists are `priv`, read through the copying
  `on_keys()` / `left_keys()` / `right_keys()`. Private and not merely
  unassignable — a public `Array` field hands back the array itself, which was
  enough to push a key into options a `LazyFrame::join` had already captured
  and change what the built plan collected. `how` / `suffix` / `coalesce` stay
  public: they are immutable values. The engine keeps its defensive check for
  the mixed state, now reachable only from an in-package test.
- The storage-backend surface leaves the public API. `Series::storage` /
  `storage_kind` / `is_canonical` / `mean_opt` and `DataFrame::to_scalar_matrix`
  are marked `#doc(hidden)` `#internal(engine, ...)`: they stay `pub` because
  another package needs them — `internal/kernel` reads a column's storage,
  `io` reads a frame's cells in one pass, and the backend the operators in
  `frame` return is asserted by tests that live in `frame` — but they no longer
  appear in the generated interface and using them from outside the module
  warns. Handing out a `ColumnStorage` / `StorageKind` was the last public leak
  of the `internal/column` types — `frame`'s interface no longer imports that
  package at all.
- The methods that *forced* a backend are gone rather than hidden:
  `Series::to_numeric` / `to_builtin` and `DataFrame::to_numeric` /
  `to_builtin`. Nothing in the engine called them — a column reaches the
  unboxed fast path from its own content, through `try_column_to_numeric`, with
  nothing a caller can say to put it there or take it away — so they were
  public only for the tests that forced a
  backend to compare the two. Those tests now build the pair they compare out
  of the ordinary constructors: `from_ints` lands on `Numeric`,
  `from_int_options` on `Builtin`.
- The engine seams leave the public interface. `series` publishes **no** free
  functions at all now: `gather_series` / `gather_series_opt` / `slice_series`
  / `preserve_backend` / `try_column_to_numeric` / `validity_bools` /
  `mask_true_indices` / `coalesce_columns` / `reducer_for` /
  `scalars_to_series` / `key_cell` and the
  `ReduceOp` / `KeyCell` types are `#internal`, as are `io`'s
  `read_csv_projected` / `read_ndjson_projected`. The full set, with
  signatures, is pinned in `.github/scripts/engine_seams.snapshot`; none of it
  is a compatibility promise.
- The shared primitives move behind a hard package boundary rather than being
  hidden where they were: `internal/text` now owns `compare_string_lex`,
  `escape_debug`, `is_decimal_int_literal`, `parse_decimal_int_opt`, and
  `parse_plain_double_opt`; `internal/numeric` owns `fold_extremum`,
  `double_fits_int64`, and the exact Int/Double comparison primitives
  (`int64_eq_double` / `int64_lt_double` / `double_lt_int64`), which `types`
  imports and calls rather than declares; and the new `internal/literal` owns
  the shared literal renderer (`format_scalar_literal`, now `format_scalar`).
  These are not `#internal` symbols in their old homes — a downstream module
  cannot import an `internal/` package at all. The facade stops re-exporting
  the three of them it used to publish; `types` and `series` are now
  types-and-methods only.
- The duplicate entry points are gone. `col` / `lit` are plain free functions
  (the `Expr::col` / `Expr::lit` static methods they were generated from are
  removed, matching the `lit_int` family); `Expr::explain` — an exact alias of
  `Expr::to_string` — is removed, while `LazyFrame::explain`, which renders a
  real plan, stays; `LazyFrame::from` is removed in favour of `LazyFrame::LazyFrame(df)`;
  and the internal aliases `NumericColumn::from_int64s` / `from_doubles` and
  the `ColumnStorage::from_builtin` / `from_numeric` wrappers collapse into
  `from_ints` / `from_floats` and the enum variants.
- `Series::new` / `Series::from_builtin`, which took a storage backend, are
  package-private — not engine seams but plain `fn`s, since nothing outside
  `series` called them. `Series::from_*` are the public constructors.
- The JSON entry points join the four-verb grid: `parse_json_records_str` /
  `format_json_records` / `write_json_records` are now `parse_json_str` /
  `format_json` / `write_json`, matching `read_json` and the CSV / NDJSON
  spellings. The records wire shape is unchanged and documented on the
  functions.
- `DataFrame::take` is renamed `DataFrame::gather`, so the row-selection verb
  shares one name with `Series::gather` (and with Polars, which renamed `take`
  to `gather` in 0.19).
- The regex string ops fold into their literal namesakes:
  `str_contains_regex` / `str_replace_regex` / `str_replace_all_regex` are
  replaced by `literal? : Bool = true` on `str_contains` / `str_replace` /
  `str_replace_all`. The default preserves v0.5 behaviour (literal matching) —
  the opposite of Polars' regex-first default — and `explain` now renders the
  regex forms as the spelling that builds them, e.g.
  `col(s).str_contains("a.c", literal=false)`.
- `Expr::children` / `referenced_columns` / `output_name` join the engine seams
  (`#internal`, absent from the generated interface); Polars keeps the
  equivalents behind its `.meta` namespace. `expr`'s interface no longer
  exposes `Set` either.
- `LazyFrame::unique` gains the `keep? : KeepStrategy` the eager `DataFrame::unique`
  gained earlier in this same line — the two had silently diverged while both
  were unreleased (and the docs already described the lazy verb as taking it;
  v0.5.8 shipped neither). A non-default strategy shows in
  `explain` as `UNIQUE keep=Last` / `UNIQUE keep=None`.
- `SortOrder` / `NullOrder` move from `frame` to `types`, so `Series::sort` can
  name them without depending on `frame`. The facade re-exports them from their
  new home, so unqualified use through `@moonframe` is unchanged; code that
  wrote `@frame.SortOrder` must write `@types.SortOrder`.
- `unique` gains Polars' `subset` on both surfaces:
  `DataFrame::unique(subset? : Array[Expr], keep? : KeepStrategy)` and the same
  on `LazyFrame`. The key is formed from the named columns while the output
  still carries every column. Resolving those names makes the eager verb
  **raising** (`ColumnNotFound`) where it used to be total — the reason this
  was held back until a breaking release. With no `subset` it cannot fail, so
  the all-columns form behaves exactly as before. A subset renders in `explain`
  as `UNIQUE ON [col(a)]`.
- `CsvReadOptions.null_values` is a private field with a copying
  `null_values()` accessor. Both it and the constructor copy, so the token list
  a reader (or a captured `scan_csv` plan) uses can no longer be mutated
  through the options value.
- `Field`'s representation goes private. `name` / `dtype` / `nullable` are
  `priv`, read through the accessors of the same names that already existed. A
  public field is not only readable but *matchable*, and a struct pattern must
  name every field or carry `..` — so leaving them public would have made any
  field the type gains later a source-breaking change, the opposite of what its
  docstring promised. The options records keep their readable fields
  deliberately: inspecting them is their read API, at the cost `api.md` now
  states.
- **Every canonical constructor is now the type's own name, spelled
  `Type::Type(...)`.** `DataFrame::new` becomes `DataFrame::DataFrame`,
  `Schema::new` becomes `Schema::Schema`, and the lazy entry point
  `lazy_frame(df)` becomes `LazyFrame::LazyFrame(df)`, joining `Field::Field`,
  `HtmlOptions::HtmlOptions`, and the three IO options types. One rule now
  covers the whole surface: a type with a single canonical entry point is built
  through its own name, and a type with genuinely different entry points keeps
  a named constructor per shape (`DataFrame::empty` / `from_rows`, the eight
  `Series::from_*`, `JoinOptions::on` / `left_on` / `cross`, `ChartSpec::bar` /
  `line` / `point` / `area`).
  - No constructor is exposed as a free function any more: the `#as_free_fn`
    forms are gone, and with them the last `lazy_frame` free function, which
    the facade re-exported. `Type::Type(...)` is the one spelling, and it reads
    the same through the facade (`@moonframe.DataFrame::DataFrame(...)`) as
    through a direct package import (`@frame.DataFrame::DataFrame(...)`).

### Fixes

- `agg` raises `LengthMismatch` when a group's reduction is not one cell. A
  `map_batches(returns_scalar=true)` closure is only *declared* to reduce; the
  shape gate is structural, so whether the closure honours the declaration is
  knowable per group. One returning several cells silently kept the first, and
  one returning none indexed an empty array and aborted. Both now meet the same
  length contract every other expression consumer enforces — which is what the
  method's own documentation already promised.

- A `map` whose result is all null borrows a *literal* input's dtype, as its
  documentation described. The fallback always read the leftmost evaluated
  input, literal or column; only the prose, and the `Unsupported` message for the
  case that has no input at all, called that case "column-less". The message now
  says "no input expression" — the condition it always tested.

- A deeply aliased expression no longer overflows the stack. `with_alias` stacks
  without bound, and two readers peeled those aliases *recursively* to answer a
  question about the expression underneath: whether a projection only renames a
  column (so its declared `nullable` rides along) and whether an aggregation is
  the plain `col(n).<agg>()` its fast path handles. Both peel with a loop now,
  like every other expression walk in the engine, so a hundred thousand aliases
  resolve where they used to abort.

- Three costs the notes in [`performance.md`](performance.md) already claimed:
  `count` on a *sliced* nullable column reads bytes again (a zero-copy slice
  advances its validity view's bit offset, and the popcount path bailed to one
  read per row whenever that offset was not a multiple of 8 — on exactly the
  columns slicing produces); a `Schema` resolves a name in `O(1)`, building its
  `name → index` map in the pass that already rejected duplicates, where
  `index_of` / `field` / `select` / `rename` used to walk the field array once
  per name (`O(c²)` to project `c` columns); and comparing two columns no longer
  materialises both validity masks as dense arrays before it has even compared
  their dtypes. No result changes — these are the same answers, arrived at the
  way the page says.

- `slice` reports a negative `end` as `IndexOutOfBounds`, the error its
  documentation always promised for an index outside the frame. Only
  `end > nrows` counted as out of range, so `df.slice(0, -1)` fell through to
  the `start > end` arm and blamed the ordering — which every valid `start`
  trivially violates against a negative `end`. `DataFrame::slice`,
  `Series::slice`, both column backends and the deferred `LazyFrame::slice`
  now test both ends against the whole valid range, so the variant says which
  argument is wrong.

- `round(decimals=N)` no longer perturbs a value it cannot round. Asking for
  more places than a `Double` can resolve is the identity, but the evaluator
  scaled by `10^N` and divided back regardless — and above `10^22` that scale
  is itself inexact, so the round trip moved the value by an ulp:
  `round(decimals=20)` turned `123456.789` into `123456.78900000002`, and
  `1.5` came back as `1.4999999999999998` at 99 places. The value is now
  returned unchanged once the requested place is finer than its own
  resolution; the overflow case that already short-circuited is folded into
  the same test.

- `with_row_index(offset=…)` refuses an offset its counter cannot reach
  instead of wrapping through it. With `offset` within `nrows` of
  `Int64::MAX` the additions carried round to `Int64::MIN`, so the column
  documented as a dense increasing counter ended on a negative jump. It is now
  `InvalidOperation`, as in Polars; the far negative end is untouched, since
  counting only ever goes up.

- Renames and the no-op column ops no longer drop a declared
  `nullable = false`. `DataFrame::rename` / `rename_with` edit each field
  through `Field::rename`, so a rename changes the name and nothing else, and
  `with_columns([])` / `drop([])` / `rename([])` return their input frame
  instead of rebuilding it through the `DataFrame` constructor, whose derived
  schema names every field at the `Field` constructor default. A `from_rows` frame carrying
  an explicit `nullable = false` therefore stays equal to itself across those
  calls — the derived `Eq` compares schemas — and a renamed column keeps the
  constraint its caller declared.

- **A declared `nullable` now survives the projections too.** `select`, `drop`,
  `with_columns` and `with_row_index` re-derived the whole schema, so a
  `nullable = false` column came back `nullable = true` after a call that never
  touched its cells — `df.drop([col("other")])` or a `with_columns` that only
  appended. Metadata now follows the cells it describes: an entry that merely
  moves a column (a bare `col("x")`, an aliased one, or a column the call
  leaves in place) carries that column's `Field`, and only an entry that
  *computes* — arithmetic, an aggregation, a `cast`, `fill_null` as an
  expression — derives a fresh one. A `with_columns` replacement inherits from
  whichever column now supplies the cells, and `join` / `group_by(...).agg(...)`
  / the summary frames still derive, since they can introduce nulls the
  declaration never covered.

## v0.5.8 — string-ordering and parse-overflow fixes

A fix patch. Every v0.5.7 symbol and signature is unchanged — the root facade
interface (`pkg.generated.mbti`) is byte-for-byte identical — so no code that
imports MoonFrame needs to change. Two internal correctness fixes, surfaced by a
static review, refine edge-case behaviour.

### Fixes

- String ordering (`compare_string_lex`, and everything routing through it —
  `Series::sort` / `min` / `max`, `DataFrame::sort`, and the `Scalar`
  comparisons) now compares by Unicode code point rather than raw UTF-16 code
  unit. Supplementary-plane characters (emoji and other astral-plane code
  points) sort by their true scalar value instead of being ranked below
  high-BMP characters by their leading surrogate unit. Ordering within the Basic
  Multilingual Plane — the common case — is unchanged.
- Float parsing (`parse_plain_double_opt`, behind CSV / JSON type inference and
  the String→Float cast) now decides IEEE 754 overflow structurally: a valid
  finite decimal literal that overflows `Double` rounds to a signed `Infinity`,
  and anything else stays unparsed. Parse results are unchanged; the fix removes
  a dependency on the standard library's human-readable "value out of range"
  message, which a future toolchain could reword.

## v0.5.7 — internal syntax modernization

An internal-refactor patch. Every v0.5.6 symbol, signature, and behaviour is
unchanged, so no code changes are required to upgrade — this release only
rewrites the implementation with current MoonBit syntax. There is no observable
difference: the root facade interface (`pkg.generated.mbti`) is byte-for-byte
identical, and every rendered output (HTML / Markdown / CSV / plan / expression)
is pinned unchanged by the existing exact-output tests. The `expr` / `frame` /
`lazy` sub-package interfaces show only `#as_free_fn` / `#alias` attributes
moving onto their methods — `col` / `lit` / `limit` remain callable exactly as
before.

### Modernized internals

- The cast helpers `cast_cells` and `cast_cells_total` are merged into one shell
  over MoonBit's error polymorphism (`raise?`): the error effect now follows the
  per-cell callback, so a total cast stays total and a fallible one stays
  fallible without a duplicated body.
- The `col` / `lit` free constructors are generated from `Expr::col` /
  `Expr::lit` with `#as_free_fn` instead of hand-written forwarders, and `limit`
  is an `#alias` of `head` on both `DataFrame` and `LazyFrame`.
- The `JoinOptions` and `HtmlOptions` `with_*` setters use struct-update
  (`{ ..self, field: value }`); the `DataType` / `Scalar` boolean predicates use
  the `is` pattern; and `Series::to_scalars` uses an index+value comprehension.
- The HTML, logical-plan, and expression renderers assemble their output with
  the `<+` template-write operator (byte-for-byte identical output).

## v0.5.6 — benchmark suite

An additive patch. Every v0.5.5 symbol and signature is unchanged, so no code
changes are required to upgrade. This release adds a `moon bench` micro-benchmark
suite and tightens module metadata and documentation.

### Benchmarks

The four packages that own execution carry a `bench_test.mbt` file driving
`moon bench`:
`series` reductions contrasting the `Numeric` fast path against `Builtin`,
`frame` `sort` / `group_by` / `join` / `filter`, `io` string parsing, and an
eager-vs-lazy pipeline — at 1K / 100K / 1M rows where scaling is informative.
The benches are ordinary test blocks, so `moon check` compiles them and
`moon bench` runs them — both in CI, so a broken benchmark fails the build.
There is no performance threshold (timings are machine-dependent). See
[`performance.md`](performance.md#benchmarks).

### Metadata and docs

- `moon.mod` now declares `supported_targets` (`wasm` / `wasm-gc` / `js` /
  `native`), matching the backends CI tests instead of implicitly claiming all.
- Doc corrections: the lazy pipeline's bitwise-equality claim is qualified for
  pruned-column parse errors, `scan` is described as deferred + projection
  pushdown rather than streaming, the coverage wording matches the tooling, and
  the `Int` sum's `Int64` overflow wrap is called out.

## v0.5.5 — Assertable representation invariants

An additive patch. Every symbol and signature is unchanged from v0.5.4, so no
code changes are required to upgrade; three internal invariants that were held
by convention — and observed only indirectly — are now surfaced so a test can
assert them directly.

### Backend-canonicalisation invariant

`Series::is_canonical()` reports whether a column sits on its content-determined
storage backend — the fixed point of the internal `try_column_to_numeric`
convergence, where a `Builtin` all-valid `Int` / `Float` column is the one
non-canonical shape (it can still move onto the `Numeric` fast path). This is
the invariant the query optimizer relies on for `collect ≡ eager`; it was upheld
by scattered canonicalisation calls and observed only through `storage_kind` or
the differential fuzzer, and is now directly assertable.

### Null-placeholder invariant

`BuiltinColumn::placeholders_normalized()` reports whether every null slot holds
its dtype's canonical placeholder (`0` / `0.0` / `false` / `""`). Because
`BuiltinColumn` derives `Eq` over the raw `data` array (null slots included),
that placeholder is what keeps two logically equal columns equal; the predicate
makes the invariant every constructor, cast, and row transform maintains
assertable rather than trusted at each write site.

### Advisory nullability is pinned

`Field.nullable = false` is advisory — only `DataFrame::from_rows` enforces it,
and every schema-rebuilding op resets it via `Field::new`. The "not propagated"
half of that contract is now pinned by a test: a `nullable = false` column
projected through `select` comes back `nullable = true`.

## v0.5.4 — API-consistency aliases and facade completeness

An additive patch. Every symbol and signature is unchanged from v0.5.3, so no
code changes are required to upgrade; a few consistency aliases and a facade
re-export fill small gaps, over a round of internal restructuring.

### API-consistency aliases

- `NumericColumn::from_ints` / `from_floats` join `from_int64s` / `from_doubles`
  as aliases, matching the `from_ints` / `from_floats` spelling every other
  layer (`Series`, `BuiltinColumn`) already uses.
- `JoinOptions::with_left_on(keys)` mirrors `with_right_on`, so either side's
  key set can be (re)supplied anywhere in a builder chain.
- `DataFrame::limit(n)` is a Polars-style alias of `head(n)` — the eager twin
  of `LazyFrame::limit`.

### Facade completeness

`format_scalar_literal` — the scalar display-syntax renderer behind the `expr`
and `lazy` `explain` output — is now re-exported from the `@moonframe` facade,
so it is reachable without importing `@types` directly.

### Internal restructuring (no behaviour change)

The expression evaluator (`frame/expr_eval.mbt`) and the query optimizer
(`lazy/optimize.mbt`) were each split into an entry shell plus focused
per-operator / per-pass files; the elementwise kernels gained `Numeric` fast
arms; and a batch of structure cleanups from an architecture-smell review
landed. No API, behaviour, or output change.

## v0.5.3 — correctness and robustness

A patch release. Every symbol and signature is unchanged from v0.5.2, so no
code changes are required to upgrade; the behavioural deltas are listed in
[`migration.md`](migration.md). What changed is a batch of correctness,
numerical, and robustness fixes from an adversarial review, plus a curated
strict-warning gate.

### Chained null-filling is linear

`col("x").fill_null(a).fill_null(b)…` (and the `fill_nan` equivalent) built an
exponentially-sized expression tree, because the old lowering to a guarded
ternary embedded the operand twice. `fill_null` / `fill_nan` are now dedicated
expression nodes carrying `(operand, value)` once, so a chain of _n_ fills is
_O(n)_ to build, render, evaluate, and compare. Results are unchanged.

### Shared lazy subplans run once

When a `LazyFrame`'s logical plan reused a subplan — a frame branched into two
downstream operations and then recombined — the executor recomputed that
subplan once per reference. It now memoises by node identity, so each distinct
subplan is executed exactly once.

### Numerical and bounds hardening

- `Series::variance` / `std` saturate to `+inf` when a finite input overflows
  Welford's intermediate delta, instead of returning a spurious finite value.
- Out-of-range indices in the row-gather path (reached through `DataFrame::take`
  and the join planner) yield null cells on both storage backends, never a
  panic.
- A read projection that names no column present in the header falls back to a
  full read rather than yielding an empty frame.
- Smaller fixes round out the release: unpaired UTF-16 surrogates are rejected
  at the file-write boundary, the grouped-aggregation dtype probe is deferred
  until the cells leave it undecided, and a grouped handle is re-validated
  before aggregation.

### Immutability at every boundary

The defensive-copy guarantee introduced in v0.5.2 for the raw constructors now
extends to every `pub` constructor and builder boundary, so no caller-supplied
array is aliased into a frame's internals.

### Tooling

A curated strict-warning gate (`missing_doc`, `prefer_readonly_array`,
`unused_default_value`, and more) is enabled through `moon.mod`'s `warnings`
field, keeping the whole tree warning-clean in CI.

## v0.5.2 — non-nullable enforcement and immutable ingestion

### `from_rows` enforces declared non-nullability

`DataFrame::from_rows` now honours a field's declared `nullable = false`: row
data that places a `Scalar::Null` in such a column raises the new
`DataError::NullInNonNullable(name)` rather than silently building a frame whose
schema contradicts its data. Callers that relied on the old silent behaviour
should declare the field `nullable = true` (the default).

This closes the only path where schema and data could disagree. `Field::new` /
`DataFrame::new` and the IO readers always declare columns `nullable = true`, so
a `nullable = false` field only ever comes from an explicit
`Field::with_nullable(..., false)` in a caller-supplied schema, and `empty`
builds 0-row columns that cannot violate it. The flag stays advisory otherwise:
it is not inferred from a column's contents nor propagated across operations.

### Raw constructors copy their input

The raw `Series` / column constructors (`from_ints` / `from_floats` /
`from_bools` / `from_strings`, and their `BuiltinColumn` / `NumericColumn`
equivalents) now defensively copy the array they are handed, so a constructed
`Series` is a true immutable value — mutating the source array afterwards no
longer changes the series' cells. The `from_*_options` constructors already
copied while boxing into `Option`; this brings the raw fast-path constructors in
line. Internal zero-copy reuse (`to_builtin` widening, `to_numeric` conversion)
is preserved through direct construction, so the copy is paid once at the
ingestion boundary, not on internal moves.

## v0.5.1 — install docs

A documentation-only patch. The README's install instructions now use `moon add
ihb2032/MoonFrame` (the package is published on
[mooncakes.io](https://mooncakes.io/docs/ihb2032/MoonFrame)), replacing the
pre-publication `git clone` / local-dependency steps. No library, API, or
behaviour changes.

## v0.5 — one expression engine

The breaking release that finishes what v0.4 started: the eager and lazy
surfaces **converge onto a single, Polars-shaped expression engine**, and the
parallel spellings that grew up alongside it are retired. (This section once
called v0.5 the last breaking release; it was not — see the entries above.)
The source-level upgrade steps are collected in [`migration.md`](migration.md).

### One engine for the four verbs

`select` / `filter` / `agg` / `with_columns` each take `Expr`s now, on both
`DataFrame` and `LazyFrame`; the v0.4 `select_exprs` / `filter_where` /
`agg_exprs` twins, the `AggSpec` reduction specs, and the closure `filter` are
all gone. The closure's per-row power moves *into* the engine as two escape
hatches — `col("q").map_elements(label, f)` (one input) and
`map_many(label, inputs, f)` (several) — still a closure over the row's cells as
`Scalar`s, but carried by an inspectable, pushdown-able `Expr` rather than an
opaque function. And because the verb *is* the expression form, a reduction can
run over a derived column: `(col("revenue") - col("cost")).sum()`.

### Expression keys everywhere

`sort` (renamed from `sort_by`), `group_by`, `join`, and the `drop` family name
their keys with `Expr`, so a key can be derived rather than just a column name.
`join` collapses the per-type `inner_join` / `left_join` / `right_join` /
`outer_join` / `cross_join` methods into the single `join(other, JoinOptions)`
(Polars has no `*_join`) and gains `left_on` / `right_on` for differently-named
keys. `sort` keeps one deliberate non-Polars behaviour — a `NaN` sorts as
missing, by the tuple's `NullOrder` (the v0.2 choice).

### A wider expression vocabulary

- **Aggregations** `std` / `variance` / `median` / `n_unique` / `first` /
  `last` join `sum` / `mean` / `min` / `max` / `count` (sample statistics for
  `std` / `variance`; `median` skips `NaN`; `first` / `last` are positional).
- **A string namespace**: `str_to_uppercase` / `str_to_lowercase` /
  `str_strip_chars` / `str_len_chars` / `str_contains` / `str_starts_with` /
  `str_ends_with` / `str_replace` / `str_replace_all` (literal matching, no
  regex), each a first-class, introspectable `Str` node.
- **`fill_null` on the expression layer**: `col("x").fill_null(value)` (the
  value is any `Expr` — a literal, another column for a coalesce, or a tree),
  plus a whole-frame `df.fill_null(value)`. The old per-column frame method is
  removed.
- **NaN probes and `fill_nan`**: the `is_nan` / `is_not_nan` tests and
  `fill_nan(value)`, the dual of `fill_null` that replaces a `Float` `NaN`
  (a value, distinct from a missing `null`) while leaving nulls in place.
- **`lit_series`** embeds a `Series` as a (broadcasting) expression, and
  **`cols(["a", "b"])`** expands names to `col` expressions.

### Row access, reductions, and dedup, Polars-shaped

The rich `RowView` is retired: `df.row(i)` returns an `Array[Scalar]` (a
positional row) and the new `df.rows()` returns them all. The column-scalar
reductions give way to Polars' pair — `df.sum()` / `mean()` / `min()` / `max()`
/ `count()` reduce to a **one-row `DataFrame`**, while a single scalar comes from
`df.get_column(c).sum()`. `df.unique()` drops duplicate rows, keeping
first-appearance order.

### Lazy file sources

`scan_csv` / `scan_ndjson` (and their `_with_options` variants) start a lazy
plan straight from a file. The optimizer's **projection pushdown** reaches into
the source: a column the plan never reads is never parsed, so
`scan_csv("sales.csv").select([col("region"), col("revenue")]).collect()` reads
only those two columns. (An array-shaped JSON document has no row-wise scan to
push a projection into, so there is no `scan_json`.)

### A canonical storage backend

A column's storage backend — the unboxed `Numeric` fast path versus the general
`Builtin` backend — now follows its *content* on the paths that canonicalise,
rather than the constructor that happened to build it: any row gather (`filter`
/ `gather` / `take` / `drop_nulls`, a grouped key, an aggregation) that leaves
an all-valid Int / Float column re-converges it onto `Numeric`. This closes a
predicate-pushdown soundness gap — sinking a `Filter` below a stage carrying a
*derived* column or group key (say `group_by([col("a") + col("b")])`) recomputes
that column over the surviving rows, and without the canonical form it could
land on a different backend than the eager chain, which `Series` equality
observes, making `collect` diverge from `execute`. Convergence is not
universal — the backend-*preserving* transforms (`slice` / `head` / `tail`) hand
the source's backend through instead, and they copy the sliced row data, sharing
only the parent's validity bitmap as a zero-copy view. Which paths canonicalise
and which preserve is listed in [`performance.md`](performance.md#the-numeric-fast-path).

### `Series` in its own package; naming finalised

`Series` is extracted from `frame` into a new `series` package, so the
expression layer can build on the per-column unit (the facade name
`@moonframe.Series` is unchanged). The last non-Polars names are aligned:
`min_value` / `max_value` → `min` / `max`, `take` → `gather`, `unique_count` →
`n_unique`, `to_int` / `to_float` / `to_string_series` → `cast`,
`DataFrame::get(i, c)` → `item(i, c)`, and `format_csv_str` → `format_csv` (so
the string serialisers share the prefix-free `format_*` shape of
`format_json_records` / `format_ndjson` / `format_vega_lite`); `null_rate` is
removed.

### Chart colour type override

`ChartSpec::with_color_type(VegaType)` overrides the Vega-Lite field `type` of
a chart's `color` channel (`Quantitative` / `Nominal` / `Ordinal` /
`Temporal`), so a numeric grouping column (a cluster id, a year) renders as
distinct per-group colours instead of the continuous gradient `quantitative`
would give.

### A null-tolerant `map`

An all-null `map_elements` / `map_many` result — every cell the closure returns
is null — now falls back to its input column's dtype and yields an all-null
column, rather than raising `Unsupported` for want of a dtype witness, matching
Polars' tolerance of a null-returning map. A grouped `agg` over such a map
(every cell in a group null) therefore completes, reducing the all-null group
normally, instead of failing mid-aggregation; only a column-less
`map_many([], …)` with no input to borrow a dtype from still raises. Alongside,
a batch of internal micro-optimizations — `fill_null` and `join` / `take`
validity gathers, the CSV null-token test, `count_distinct` — with no API or
behaviour change.

## v0.4 — shipped

A Polars-style expression engine and a lazy query layer, both **purely
additive** on top of the v0.3 core — two new packages (`expr`, `lazy`) and new
`DataFrame` / `GroupedDataFrame` methods, with nothing changed in the v0.2 /
v0.3 surface (nothing for [`migration.md`](migration.md)). Also folds in the
post-v0.3 whole-library review's join follow-ups.

### Expression engine (the `expr` package)

A reified, composable column expression. `col("name")` and `lit_int` /
`lit_float` / `lit_str` / `lit_bool` / `lit` build the leaves; the overloaded
operators `+ - * /` (arithmetic — `/` is always `Float`, dividing by zero to
IEEE `±inf` / `NaN` rather than trapping), `&` / `|` (Kleene-logical, **not**
bitwise), and unary `-` compose them; and the methods `eq` / `ne` / `lt` /
`le` / `gt` / `ge` (comparisons → `Bool`), `not` / `is_null` / `is_not_null`,
the aggregations `sum` / `mean` / `min` / `max` / `count`, `cast`, and
`with_alias` extend them. `when(cond).then(a).otherwise(b)` is a row-wise
conditional. Building a tree is **total** (it never fails); `explain()` and the
`Show` impl render the operator form, and `referenced_columns` / `output_name`
introspect it. An `Expr` is read-only outside `expr` — built through the
surface above, never by naming a variant.

### Eager expression consumers (the `frame` package)

The whole-frame evaluator and its `DataFrame` / `GroupedDataFrame` consumers:
`with_columns` (derive or replace columns), `select_exprs` (project to the
evaluated expressions — an all-aggregation selection collapses to one row),
`filter_where` (vectorized boolean row selection — a reified, pushdown-able
alternative to the closure `filter`), and `agg_exprs` (the expression form of
`agg`, generalising `AggSpec` to compound reductions like
`(col("revenue") - col("cost")).sum()`). Evaluation is vectorized with
`Int` / `Float` promotion, null propagation, Kleene logic, and the `Series`
reduction's `NaN` rules.

### Lazy query layer (the `lazy` package)

`lazy_frame(df)` (or `LazyFrame::from(df)`) starts a deferred plan; total
builder methods mirroring the eager verbs grow it; `explain()` prints it; and
`collect()` runs it. With no optimizer in front, a collect is bitwise-equal to
the eager pipeline. The optimizer adds two result-preserving rewrites —
**predicate pushdown** (sink each filter toward the scan, past the stages it
provably commutes with) and **projection pushdown** (insert a narrowing
selection over a scan whose consumers read only a subset of its columns) — so
`explain()` versus `explain(optimized=true)` is a before/after view of what the
optimizer moved and pruned. `LazyFrame::group_by(keys).agg(exprs)` is the lazy
mirror of the eager grouping.

### Join — duplicate-key check and backend preservation

- `join` now rejects a **key repeated in `on`** with `DuplicateColumn`,
  matching `group_by(["id", "id"])` and `select`'s "no duplicate keys"
  contract (previously `on = ["id", "id"]` silently behaved as the single key
  `["id"]`). A *missing* repeated key still surfaces as `ColumnNotFound` at its
  first appearance.
- Join output columns now **preserve the storage backend** of their source
  where they pick up no unmatched-row null — an all-valid `Numeric` source
  column stays `Numeric` instead of demoting to `Builtin`, matching `filter` /
  `sort_by` / `take` / `drop_nulls` / `fill_null`. Only the representation
  changes; values and dtypes are identical.

## v0.3 — shipped

Output formats, the full join matrix, read resilience, and a pluggable
column-storage backend, all on top of the v0.2 method-chain core. The
source-level upgrade steps are in [`migration.md`](migration.md).

### HTML rendering (output format)

`df.to_html()` renders a `<table>` — a `<thead>` header over a `<tbody>` of
rows, with a null cell rendered as `<td></td>` — and
`df.to_html_with_options(...)` adds a CSS `class`, a `<caption>`, and (via
`HtmlOptions::with_max_rows`) a row cap with a `<tfoot>` `... (K more rows)`
banner. Header and cell text is HTML-escaped (`&` / `<` / `>` / `"`) by
default; `with_escape(false)` passes trusted markup through. Like
`to_markdown`, it is a pure, dependency-free `DataFrame` method (the IO-1
boundary keeps rendering in `frame`).

### Vega-Lite chart export (output format)

`format_vega_lite(df, ChartSpec::bar("region", "revenue"))` emits a complete
[Vega-Lite v5](https://vega.github.io/vega-lite/) specification — `$schema` +
optional `title` + `mark` + `encoding` + an inline `data.values` array — as a
JSON string you can paste straight into the
[Vega editor](https://vega.github.io/editor/) or feed to any Vega-Lite runtime.
`ChartSpec::bar` / `line` / `point` / `area` choose the mark; `with_color` adds
a grouping column and `with_title` a heading; each channel's field `type` is
inferred from the column dtype (numeric → `quantitative`, else `nominal`), and
cells follow the JSON-records conventions (null / non-finite floats → JSON
`null`). Being an `io` serialiser (parallel to `format_json_records`), a spec
that names a missing column raises `ColumnNotFound`; `write_vega_lite` is the
file wrapper.

### Join matrix completed — Right / Outer

`JoinType` gained `Right` / `Outer`, so the matrix is now the full
`inner` / `left` / `right` / `outer` / `cross`. `left.inner_join(right, ["id"])`,
`.left_join` / `.right_join` / `.outer_join`, or the configurable
`left.join(right, JoinOptions::on(["id"]).with_how(Outer).with_coalesce(true))`
do a hash equi-join with Polars-aligned semantics: a **null** key matches
nothing (`null != null`, as in SQL / Polars), a `NaN` key matches other NaNs,
the right-column collision suffix defaults to `"_right"`, and key columns are
coalesced on an inner join but kept (the right as `id_right`) on a
left / right / outer join — `coalesce` defaults to Polars' per-`how` rule and is
overridable via `with_coalesce` (a coalesced key takes each row's value from
whichever side is present: the left for `inner` / `left`, the right for `right`,
the present side per row for `outer`). Output is the left columns then the right
columns, rows in deterministic order. `left.cross_join(right)`
(`JoinType::Cross`) gives the keyless Cartesian product.

### CSV / JSON / NDJSON read resilience

All three readers' option structs gained escape hatches for messy inputs.
`infer_schema_rows = 0` (or any value `<= 0`) now scans *every* row rather than
a leading window (Polars' `infer_schema_length=None`), so a dtype that only
resolves deep in the data is inferred instead of guessed from a prefix.
`on_parse_error` (`OnParseError::Raise`, the default, or `Null`) chooses what
happens when a non-null cell past the inference window doesn't fit its column's
locked-in dtype: fail with `ParseError(Cell(...))` (lossless) or
downgrade that one cell to a null and keep going (Polars'
`ignore_errors=True`), with the column keeping its inferred dtype. CSV
additionally gains
`allow_nonfinite_floats` (default `true`): set it `false` to stop a column of
`nan` / `inf` / `infinity` tokens from being silently inferred as `Float`,
falling back to `String` instead. These are `pub(all)` struct field additions —
see [`migration.md`](migration.md).

### Pluggable column storage (engineering depth)

A `Series` now holds a `ColumnStorage` — a closed `{ Builtin; Numeric }` seam —
instead of a bare `BuiltinColumn`. `Builtin` is the general-purpose Arrow column
(any dtype, nullable); `Numeric` is an all-valid, unboxed `Int64` / `Double`
column that carries **no validity bitmap**, so it skips the bitmap allocation on
construction and the per-slot validity check in its reductions (the
`null_count == 0` fast path). The no-null `Series::from_ints` / `from_floats`
build `Numeric` automatically, and structural transforms (`slice` / `take` /
`drop_nulls` / `head` / `tail` / `filter` / `sort_by`) keep a column on the fast
path. `storage_kind()` reports the backend; `to_numeric()` / `to_builtin()` move
between them (per-column on a `Series`, whole-frame on a `DataFrame`) — a
lossless representation swap that leaves names, dtypes, and values unchanged.

## v0.2 — method-chain migration

The whole v0.1 surface moved to the method-chain + `raise` form (see
[`migration.md`](migration.md) for the call-site changes).

- The operator verbs (`select` / `drop` / `rename` / `with_column` /
  `replace_column` / `filter` / `sort_by` / `drop_nulls` / `drop_nulls_in` /
  `fill_null` / `null_count` / `count` / `sum` / `mean` / `min` / `max` /
  `describe`) became **methods on `DataFrame`**; the old `ops` package folded
  into `frame`.
- Every fallible operation returns `T raise DataError` instead of
  `Result[T, DataError]`.
- `filter` takes a single `(RowView) -> Bool raise DataError` predicate (the
  v0.1 `filter` / `filter_try` split is gone — a fallible accessor in the
  predicate just raises).
- `sort_by` takes an `Array[(column, SortOrder, NullOrder)]`; multi-key sort
  falls out of listing several tuples (`sort_by_many`, the `SortSpec` struct,
  and the `IntoSortSpecs` trait are all gone).
- `to_markdown` / `to_markdown_with_limit` are `DataFrame` methods; the
  CSV / JSON string serialisers (`format_csv_str` / `format_json_records`) stay
  as `io` free functions.

**GroupBy.** `df.group_by(keys).agg([AggSpec::sum("x"), AggSpec::mean("y"), ...])`
returns a one-row-per-group summary with `Count` / `Sum` / `Mean` / `Min` /
`Max` reductions (reusing the `Series` statistics, with Polars-aligned `NaN`
rules: a `NaN` propagates through `Sum` / `Mean` but is skipped by `Min` /
`Max`), optional per-column aliases via `with_alias`, deterministic
first-appearance group order (Polars' `maintain_order=True`), and null keys kept
as their own group.

**Join (inner / left / cross).** The hash equi-join landed with the
`inner` / `left` / `cross` cases; the `right` / `outer` completion came in v0.3
(see the v0.3 entry above for the full semantics).

**NDJSON I/O.** `read_ndjson` / `write_ndjson` (and the string-level
`parse_ndjson_str` / `format_ndjson`) read and write the JSON Lines format — one
JSON object per line — reusing the JSON-records type inference and
`scalar_to_json` cell conventions. Reading is lenient (blank lines skipped, CRLF
tolerated); writing emits one compact object per row, each terminated by `\n`.

## v0.1 — foundation

The initial column-oriented core: an Apache Arrow-style column layout
(byte-packed validity `Bitmap`, `1 = valid`) under `Series` / `DataFrame`, an
`O(1)` `name_to_index` cache, `DataFrame::check_invariants()` as a formal
structural spec (INV1–INV7) asserted by every operator test, and the first
CSV / JSON readers with `Int → Float → Bool → String` type inference (see
[`type-inference.md`](type-inference.md)).
