# Migration guide

Source-level breaking changes between MoonFrame releases. Pre-1.0, breaking
changes ride the minor version. For the feature history behind each release see
[`changelog.md`](changelog.md); for the API concepts and compatibility model
see [`api.md`](api.md), and the per-symbol reference on
[mooncakes.io](https://mooncakes.io/docs/ihb2032/MoonFrame).

## v0.5.8 → v0.6.0

v0.6 is a pre-1.0 breaking release — the API-convergence one. Where two
spellings existed for building or configuring the same value, v0.6 keeps a
single entry point; there are no deprecated aliases. From v0.7 on the stable
public surface evolves compatibly.

### A declared `nullable` survives the projections

`Field.nullable = false` used to be reset by any op that re-derived a schema,
so a projection could quietly widen a caller's declared constraint. It is now
carried by every op that only moves a column, and re-derived only by ops that
compute cells. Nothing needs rewriting to compile; what changes is the schema
of the result — and, through the derived `Eq`, frame equality.

| expression (over a `from_rows` frame declaring `id` non-nullable) | v0.5 | v0.6 |
| --- | --- | --- |
| `df.select([col("id")]).schema().field("id").nullable()` | `true` | `false` |
| `df.drop([col("other")]).schema().field("id").nullable()` | `true` | `false` |
| `df.with_columns([lit_int(0).with_alias("n")])` … `id` | `true` | `false` |
| `df.with_row_index()` … `id` | `true` | `false` |
| `df.select([(col("id") + lit_int(1)).with_alias("id")])` … `id` | `true` | `true` |

If you were relying on a projection to *widen* a declared field — using
`select` to hand a non-nullable column to code that expects a nullable one —
state it explicitly: rebuild through `DataFrame::from_rows` with the schema you
want, or `DataFrame::DataFrame(df.column_series())`, whose derived schema
declares everything nullable.

### A column-less frame keeps its rows

Projecting a frame to zero columns used to return `0×0`, discarding the height.
It now returns `N×0`. Nothing needs rewriting to compile — but code that
*asserted* the old shape, or that treated "no columns" as "no rows", changes:

| expression | v0.5 | v0.6 |
| --- | --- | --- |
| `df.select([]).shape()` (`df` 3-row) | `(0, 0)` | `(3, 0)` |
| `df.drop(every_column).shape()` | `(0, 0)` | `(3, 0)` |
| `from_rows(Schema::Schema([]), [[], []]).shape()` | `(0, 0)` | `(2, 0)` |
| `parse_json_str("[{},{}]").shape()` | `(0, 0)` | `(2, 0)` |
| `df.select([]).is_empty()` | `true` | `false` |

`DataFrame::DataFrame([])` is unchanged at `0×0` — it infers its height from
the columns it is given. If you relied on a zero-column result reporting
`is_empty()`, test `ncols() == 0` instead. `check_invariants()`' INV7 changed
with it: from "a column-less frame is row-less" to `nrows >= 0`.

### `Field` has one constructor, and its fields are private

| v0.5 | v0.6 |
| --- | --- |
| `Field::new("age", Int)` | `Field::Field("age", Int)` |
| `Field::with_nullable("id", Int, false)` | `Field::Field("id", Int, nullable=false)` |
| `f.name` / `f.dtype` / `f.nullable` | `f.name()` / `f.dtype()` / `f.nullable()` |

`struct Field` is opaque: a record literal (`{ name: "age", dtype: Int,
nullable: true }`) outside `types` no longer compiles — build through
`Field::Field(...)` — and the three fields are `priv`, read through the
accessors of the same names. Reading a field was the smaller half of what
public fields allowed; the larger half was destructuring one, and since a
struct pattern must name every field or carry `..`, that would have made any
field this type gains later a breaking change. The options records
(`CsvReadOptions`, `CsvWriteOptions`, `JsonReadOptions`, `HtmlOptions`,
`JoinOptions`, `ChartSpec`) keep their readable fields — inspecting them is
their read API — at that same documented cost. The exceptions are the fields
holding a mutable array, `CsvReadOptions.null_values` and `JoinOptions`' three
key lists: reading one of those would hand back the array itself, so each is
private behind a copying accessor (`null_values()`, `on_keys()` /
`left_keys()` / `right_keys()`).

### IO options are built by constructor

| v0.5 | v0.6 |
| --- | --- |
| `CsvReadOptions::default()` | `CsvReadOptions::CsvReadOptions()` |
| `{ has_header: true, delimiter: ';', infer_schema_rows: 100, null_values: [""], strict_column_count: false, on_parse_error: Raise, allow_nonfinite_floats: true }` | `CsvReadOptions::CsvReadOptions(delimiter=';')` |
| `CsvWriteOptions::default()` | `CsvWriteOptions::CsvWriteOptions()` |
| `JsonReadOptions::default()` | `JsonReadOptions::JsonReadOptions()` |
| `NdjsonReadOptions::default()` | `JsonReadOptions::JsonReadOptions()` |
| `parse_csv_str(text, options, strict_quotes=true)` | `parse_csv_str(text, options=CsvReadOptions::CsvReadOptions(strict_quotes=true))` |
| `format_csv(df, options, sanitize_formulas=true)` | `format_csv(df, options=CsvWriteOptions::CsvWriteOptions(sanitize_formulas=true))` |
| `options.null_values` | `options.null_values()` |

`CsvReadOptions`, `CsvWriteOptions`, and `JsonReadOptions` are `pub` rather than
`pub(all)`, so a record literal no longer compiles outside `io`; every field has
a constructor parameter with the previous default, and only what differs needs
naming. Their fields stay readable — with one exception, `null_values` below —
which is the point of an options record, and also why a field added later is
*not* additive: a public field can be destructured, and a struct pattern must
name every field or carry `..`. Field additions to these types ride the minor
version, like a new `pub(all)` enum variant.

`NdjsonReadOptions` is removed: what took it in v0.5 — the NDJSON reader and
the then-current `scan_ndjson_with_options` — takes `JsonReadOptions`, which has
the same two fields. (Those `*_with_options` names are themselves v0.5 spellings;
the same release folded them into the plain entry points, so the v0.6 call is
`scan_ndjson(path, options=…)`. See the table below.)

The `strict_quotes` and `sanitize_formulas` parameters are gone from the v0.5
`parse_csv_str` / `read_csv_with_options` / `read_csv_projected` and
`format_csv` / `write_csv_with_options`; set them on the options instead. The
lazy scan picks up strict quote validation this way, which the parameter form
could not express.

`CsvReadOptions.null_values` is now private, read through `null_values()`, which
returns a copy — as does the constructor, so mutating either array cannot change
what a reader (or a captured `scan_csv` plan) treats as null.

### A filtered file scan can no longer report a dropped row's parse error

`scan_csv` / `scan_ndjson` now push a predicate into the read. Nothing about
values or dtypes changes — inference still walks the whole file — but a
`ParseError` in a row the predicate drops, in a column the predicate does not
read, is no longer raised, because those cells are never parsed. This is the
same trade projection push-down has always made for dropped *columns*. Code
that relied on a lazy pipeline failing on a malformed row it filters away
should read eagerly (`read_csv(path).filter(...)`) instead.

### The JSON entry points complete the verb grid

| v0.5 | v0.6 |
| --- | --- |
| `parse_json_records_str(text)` | `parse_json_str(text)` |
| `format_json_records(df)` | `format_json(df)` |
| `write_json_records(path, df)` | `write_json(path, df)` |

`read_json` already dropped the qualifier, so the records shape now has the
same four-verb spelling as CSV and NDJSON — `read` / `write` / `parse_*_str` /
`format` — and the wire shape (`[{...}, ...]`) is documented on the functions
rather than in their names.

### `SortOrder` / `NullOrder` moved to `types`

They now live beside `DataType` / `Scalar`, because `Series::sort` (new in
v0.6) names them and `series` sits below `frame`. Through the facade nothing
changes — `SortOrder::Desc` still resolves — but a direct package import needs
the new home:

| v0.5 | v0.6 |
| --- | --- |
| `@frame.SortOrder::Desc` | `@types.SortOrder::Desc` |
| `@frame.NullOrder::NullsLast` | `@types.NullOrder::NullsLast` |

### `DataError::TypeMismatch` / `ParseError` carry structured detail

In v0.5 both variants held a flat `String`. They now hold typed detail enums —
`TypeMismatchDetail` and `ParseErrorDetail` — so a handler can branch on the
failure instead of parsing a message. The rendered text
(`DataError::message()`) is unchanged, so a `catch` that only formats the error
needs no change; a `match` that bound the string does:

| v0.5 | v0.6 |
| --- | --- |
| `TypeMismatch(msg)` — `msg : String` | `TypeMismatch(detail)` — `detail : TypeMismatchDetail` |
| `ParseError(msg)` — `msg : String` | `ParseError(detail)` — `detail : ParseErrorDetail` |
| `e.message()` | `e.message()` (unchanged) |

### `Expr` is opaque; `ClosedInterval` moved to `types`

The expression tree is no longer a matchable `pub enum Expr`. It is an opaque
handle whose AST (`ExprNode` and the `BinOp` / `UnOp` / `AggOp` / `StrOp` tags)
lives in the module-internal `internal/ir` package. Building expressions is
unchanged; code that *matched* an `Expr`'s variants from outside `MoonFrame`
must instead render it with `Expr::to_string` (the AST was never an intended
consumption surface):

| v0.5 | v0.6 |
| --- | --- |
| `match expr { @expr.Col(name) => … }` | `expr.to_string()` (no variant match) |

`ClosedInterval` (the `is_between` `closed?` argument) is new in v0.6 and lives
in `types`, beside the AST that carries it — `@moonframe.ClosedInterval` through
the facade, `@types.ClosedInterval` through a direct sub-package import. There
is nothing to move: v0.5 had no such type.

### `Expr` and `JoinOptions` no longer compare with `==`

Expression equality compared the internal tree, which made *how an operator
lowers* observable: a release that normalised a tree or merged two node kinds
would have changed what compared equal, without changing what any expression
means. The impl is removed rather than promised. `JoinOptions` holds
expressions as its key lists, so its derived equality goes too.

Compare renderings, which is what `explain()` prints and what stays stable:

| v0.5 | v0.6 |
| --- | --- |
| `assert_eq(built, col("a") + lit_int(1))` | `assert_eq(built.to_string(), (col("a") + lit_int(1)).to_string())` |
| `opts_a == opts_b` | compare `how` / `suffix` / `coalesce` and the rendered `on_keys()` / `left_keys()` / `right_keys()` |

Rendering does not distinguish what it does not print: a `lit_series` shows as
its name and length, so two literal series over different cells render alike.

### `unique` takes a subset, and is now fallible

`DataFrame::unique` gained Polars' `subset` — the columns whose values form the
duplicate key — which makes it `raise DataError` (`ColumnNotFound`) instead of
total. Existing calls keep their behaviour but now need a `raise` context (or a
`catch`), exactly like `filter` / `select` beside them:

| v0.5 | v0.6 |
| --- | --- |
| `let out = df.unique()` (total) | `let out = df.unique()` (raises) |
| — | `df.unique(subset=[col("id")], keep=Last)` |

`LazyFrame::unique` takes the same `subset?` and stays total at build time; an
unknown name surfaces at `collect`.

### One name per concept

| v0.5 | v0.6 |
| --- | --- |
| `df.take(indices)` | `df.gather(indices)` |

`Series::gather` is unchanged; `DataFrame::take` took the same name as in
Polars.

Regex string matching is new in v0.6 and arrives as a `literal? : Bool` argument
on the existing methods (`expr.str_contains(pat, literal=false)`), not as a
separate `*_regex` name. The default stays `true`, so every v0.5 call is
unaffected and renders in `explain` exactly as before.

`Expr::children` / `referenced_columns` / `output_name` are now `#internal`
(engine seams). `LazyFrame::unique` accepts `keep?` like the eager verb — a
pure addition.

### Canonical constructors are the type's own name

| v0.5 | v0.6 |
| --- | --- |
| `DataFrame::new(columns)` | `DataFrame::DataFrame(columns)` |
| `Schema::new(fields)` | `Schema::Schema(fields)` |
| `lazy_frame(df)` | `LazyFrame::LazyFrame(df)` |

Construction is one spelling everywhere: `Type::Type(...)`. These three renames
put the data types on it, and the constructors listed in the sections above
(`Field::Field`, `CsvReadOptions::CsvReadOptions`, `HtmlOptions::HtmlOptions`,
…) are written the same way — no constructor is exposed as a free function, so
the `lazy_frame` the facade re-exported is gone with them. The spelling reads
the same through the facade (`@moonframe.DataFrame::DataFrame(...)`) as through
a direct package import (`@frame.DataFrame::DataFrame(...)`).

A type whose construction genuinely has several shapes keeps a named
constructor per shape, so `DataFrame::empty`, `DataFrame::from_rows`, the eight
`Series::from_*`, `JoinOptions::on` / `left_on` / `cross`, and
`ChartSpec::bar` / `line` / `point` / `area` are unchanged.

### Duplicate entry points are removed

| v0.5 | v0.6 |
| --- | --- |
| `Expr::col("a")` | `col("a")` |
| `Expr::lit(scalar)` | `lit(scalar)` |
| `expr.explain()` | `expr.to_string()` |
| `LazyFrame::from(df)` | `LazyFrame::LazyFrame(df)` |
| `Series::new(name, storage)` / `Series::from_builtin(name, column)` | `Series::from_ints` / `from_floats` / `from_strings` / `from_bools` / `from_*_options` |

`LazyFrame::explain` is untouched — it renders an actual query plan, not a
string alias. Inside the (now private) column layer, `NumericColumn::from_int64s`
/ `from_doubles` and `ColumnStorage::from_builtin` / `from_numeric` collapsed
into `from_ints` / `from_floats` and the enum variants.

### The engine seams and text helpers are no longer public

`compare_string_lex`, `is_decimal_int_literal`, and `format_scalar_literal`
were re-exported by the facade in v0.5; they are gone from it. They — plus
`escape_debug` and the two literal parsers — now live in the private packages
`internal/text` / `internal/literal`, and `types`' `fold_extremum` /
`double_fits_int64` in `internal/numeric`; downstream code cannot import any of
them, since MoonBit refuses a cross-module `internal/` import outright. They are
not hidden-but-present in `types`: that package declares none of them any more.
Their behaviour is still part of the library's contract where it is observable:
string ordering is by Unicode code point (`Series::sort` / `DataFrame::sort` /
`Scalar::lt`), and literal parsing drives CSV / JSON type inference.

`series`' kernel functions (`gather_series`, `reducer_for`, `key_cell`, …, plus
`ReduceOp` / `KeyCell`) and `io`'s `read_csv_projected` /
`read_ndjson_projected` take the other route: they stay in place, marked
`#internal` — still `pub` for the library's own use across packages, but absent
from the generated interfaces and warned about from another module. Use the
`Series` methods and `DataFrame` verbs built on them, and `scan_csv` /
`scan_ndjson` for projection push-down.

### The storage-backend methods are engine seams

`Series::storage` / `storage_kind` / `is_canonical` / `mean_opt` and
`DataFrame::to_scalar_matrix` are no longer public API. They remain `pub`
because another package in the library needs them, but they are marked
`#internal`, so they are absent from the generated interface and calling them
from another module warns.

`DataFrame::storage_kinds`, `Series::to_numeric` / `to_builtin` and
`DataFrame::to_numeric` / `to_builtin` are gone outright rather than hidden.
Nothing selects a backend: a column takes the unboxed fast path when the
operation that produced it canonicalises *and* its content allows it (see
[`performance.md`](performance.md#the-numeric-fast-path)), and the constructors
are the closest thing to asking for one —
`from_ints` / `from_floats` build a `Numeric` column, the nullable
`from_*_options` a `Builtin` one.

They existed to expose the columnar backend, which moved to `internal/column`
in v0.6. Value-level access covers the user-facing cases: `Series::get` /
`to_scalars` and the typed constructors instead of a `ColumnStorage`,
`DataFrame::rows` / `row` / `item` instead of `to_scalar_matrix`, and
`Series::mean` (catching its error) instead of `mean_opt`.

### Builders fold into their constructors

| v0.5 | v0.6 |
| --- | --- |
| `HtmlOptions::default()` | `HtmlOptions::HtmlOptions()` |
| `HtmlOptions::default().with_max_rows(20).with_caption("S")` | `HtmlOptions::HtmlOptions(max_rows=20, caption="S")` |
| `df.to_html_with_options(opts)` | `df.to_html(options=opts)` |
| `df.to_markdown_with_limit(10)` | `df.to_markdown(max_rows=10)` |
| `JoinOptions::on(keys).with_how(Left)` | `JoinOptions::on(keys, how=Left)` |
| `JoinOptions::on(keys).with_coalesce(true)` | `JoinOptions::on(keys, coalesce=true)` |
| `JoinOptions::left_on(l).with_right_on(r)` | `JoinOptions::left_on(l, right_on=r)` |
| `JoinOptions::cross().with_suffix("_r")` | `JoinOptions::cross(suffix="_r")` |
| `ChartSpec::bar(x, y).with_color("region").with_title("T")` | `ChartSpec::bar(x, y, color="region", title="T")` |

Two shapes stop compiling by design. `JoinOptions::left_on(keys)` on its own is
gone — `right_on~` is required, so a sided join always names both sides — and
there is no longer a way to set `on` *and* `left_on` / `right_on` on the same
options, which used to be a runtime `InvalidOperation`.

An options value is built like every other constructor —
`let opts = HtmlOptions::HtmlOptions(max_rows=20)`, or inline at the call.

### `*_with_options` is folded into an optional parameter

| v0.5 | v0.6 |
| --- | --- |
| `read_csv_with_options(path, opts)` | `read_csv(path, options=opts)` |
| `write_csv_with_options(path, df, opts)` | `write_csv(path, df, options=opts)` |
| `read_json_with_options(path, opts)` | `read_json(path, options=opts)` |
| `read_ndjson_with_options(path, opts)` | `read_ndjson(path, options=opts)` |
| `scan_csv_with_options(path, opts)` | `scan_csv(path, options=opts)` |
| `scan_ndjson_with_options(path, opts)` | `scan_ndjson(path, options=opts)` |
| `parse_csv_str(text, opts)` | `parse_csv_str(text, options=opts)` |
| `parse_json_records_str(text, opts)` | `parse_json_str(text, options=opts)` |
| `parse_ndjson_str(text, opts)` | `parse_ndjson_str(text, options=opts)` |
| `format_csv(df, opts)` | `format_csv(df, options=opts)` |
| `parse_csv_str(text, CsvReadOptions::CsvReadOptions())` | `parse_csv_str(text)` |

The options parameter is optional and labelled, so it has to be named at the
call site — and a call that only wanted the defaults can drop it entirely.
`read_csv_projected` / `read_ndjson_projected`, the engine seam behind
projection push-down, keep their positional options.

## v0.5.7 → v0.5.8

No source-level migration steps. v0.5.8 is a fix patch: every v0.5.7 symbol and
signature is unchanged, the root facade `.mbti` is byte-for-byte identical, and
nothing is renamed, removed, re-signed, or given a new required `match` arm. Two
correctness fixes refine edge-case behaviour without touching any API — string
ordering now compares by Unicode code point (so supplementary-plane characters
such as emoji sort by their true scalar value; ordering within the Basic
Multilingual Plane is unchanged), and float-overflow detection during parsing no
longer depends on the standard library's error-message text (parse results are
unchanged). See the [changelog](changelog.md).

## v0.5.6 → v0.5.7

No source-level migration steps. v0.5.7 is an internal-refactor patch: every
v0.5.6 symbol, signature, and behaviour is unchanged, and nothing is renamed,
removed, re-signed, or given a new required `match` arm. The implementation is
rewritten with current MoonBit syntax with no new or changed surface, so nothing
a consumer imports differs. (The `expr` / `frame` / `lazy` sub-package `.mbti`
files show `#as_free_fn` / `#alias` attributes on `col` / `lit` / `limit`, but
those symbols stay callable exactly as before — see the
[changelog](changelog.md).)

## v0.5.5 → v0.5.6

No source-level migration steps. v0.5.6 is an additive patch: every v0.5.5
symbol and signature is unchanged, and nothing is renamed, removed, re-signed,
or given a new required `match` arm. The new surface is a `moon bench` benchmark
suite (test-scope only — nothing a consumer imports changes), a declared
`supported_targets` in `moon.mod`, and documentation corrections (see the
[changelog](changelog.md)).

## v0.5.4 → v0.5.5

No source-level migration steps. v0.5.5 is an additive patch: every v0.5.4
symbol and signature is unchanged, and nothing is renamed, removed, re-signed,
or given a new required `match` arm. The new surface is purely additive — two
introspection predicates, `Series::is_canonical()` and
`BuiltinColumn::placeholders_normalized()`, that assert internal representation
invariants (see the [changelog](changelog.md)).

## v0.5.3 → v0.5.4

No source-level migration steps. v0.5.4 is an additive patch: every v0.5.3
symbol and signature is unchanged, and nothing is renamed, removed, re-signed,
or given a new required `match` arm. The new surface is purely additive — the
API-consistency aliases (`NumericColumn::from_ints` / `from_floats`,
`JoinOptions::with_left_on`, `DataFrame::limit`) and the `format_scalar_literal`
facade re-export listed in the [changelog](changelog.md).

## v0.5.2 → v0.5.3

No source-level migration steps: no renames, no signature changes, and no new
required `match` arms. v0.5.3 is a correctness and robustness patch, so the only
changes are behavioural fixes that code should not have depended on:

- A chain of `fill_null` / `fill_nan` now builds in linear time instead of an
  exponentially-sized tree; the results are identical.
- Out-of-range indices in the row-gather path (reached through `DataFrame::take`
  and the join planner) yield a null cell instead of panicking. Code that
  relied on the panic to flag a bad index should validate indices itself.
- `Series::variance` / `std` saturate to `+inf` on intermediate Welford
  overflow rather than returning a spurious finite value.
- A read projection (`read_csv_projected` / `read_ndjson_projected`, and the
  `lazy` scans built on them) that names no column present in the header now
  falls back to reading the full file instead of yielding an empty frame.

## v0.5.1 → v0.5.2

Additive plus two behaviour fixes; no renames.

- `DataError` gains a `NullInNonNullable(String)` variant. A new enum variant
  is the one change the post-v0.5 surface allows: an exhaustive `match` over
  `DataError` without a wildcard arm must add a `NullInNonNullable(_)` case.
- `DataFrame::from_rows` now raises `NullInNonNullable(name)` when row data
  places a null in a field declared `nullable = false` (previously it built the
  frame silently). Declare the field `nullable = true` — the default, via
  `Field::new` — to keep the old behaviour.
- Raw constructors (`Series` / `BuiltinColumn` / `NumericColumn`'s `from_ints` /
  `from_floats` / `from_bools` / `from_strings`) now defensively copy their
  input array. Code that mutated the source array after construction to alter
  the column (the previously-documented footgun) no longer has that effect —
  mutate the array before constructing, or build a fresh one.

## v0.4 → v0.5

v0.5 is a pre-1.0 breaking release. (It was announced as the last one; v0.6 is
one more — the API-convergence release above — after which the public surface
evolves compatibly.) It
converges the eager and lazy APIs onto a single, Polars-shaped expression
engine. The duplicate `*_exprs` verbs, the closure `filter`, the `AggSpec`
reduction specs, the rich `RowView`, the per-type `*_join` methods, the
column-scalar reductions, and a tail of non-Polars names are all **removed
outright** — there are no deprecated aliases. Everything below is a pure rename
or a mechanical rewrite; for the feature side of the same release see
[`changelog.md`](changelog.md).

### The four verbs take expressions

`select` / `filter` / `agg` / `with_columns` each now take an `Array[Expr]` (or
a single `Expr`), on both `DataFrame` and `LazyFrame`. The v0.4 `*_exprs` /
`*_where` twins that introduced the expression form are gone — the plain verb
*is* the expression form.

| v0.4 | v0.5 |
|---|---|
| `df.select(["region", "revenue"])` | `df.select([col("region"), col("revenue")])` — or `df.select(cols(["region", "revenue"]))` |
| `df.select_exprs([col("a") + col("b")])` | `df.select([col("a") + col("b")])` |
| `df.filter(row => row.get_int("x") > 0)` | `df.filter(col("x").gt(lit_int(0)))` |
| `df.filter_where(expr)` | `df.filter(expr)` |
| `grouped.agg([AggSpec::sum("x").with_alias("t")])` | `grouped.agg([col("x").sum().with_alias("t")])` |
| `grouped.agg_exprs([expr])` | `grouped.agg([expr])` |
| `df.with_column(series)` | `df.with_columns([lit_series(series)])` |
| `lf.select_exprs(...)` / `lf.filter_where(...)` | `lf.select(...)` / `lf.filter(...)` |

The `AggSpec` struct and its `AggKind` enum (`AggSpec::sum` / `mean` / `min` /
`max` / `count` / `with_alias`) are removed; the equivalent reductions are the
`Expr` methods `col("x").sum()` / `.mean()` / `.min()` / `.max()` / `.count()`,
which additionally compose over a *derived* column — `(col("revenue") -
col("cost")).sum()` — the thing `AggSpec` could not express.

**Arbitrary row predicates.** The old closure `filter` could run any MoonBit
code per row. Its reified replacement is the `map_many` escape hatch — a closure
over the row's cells as `Scalar`s, carried by an inspectable `Expr`:

```moonbit
// v0.4
df.filter(row => row.get_string("product") == "widget")
// v0.5
df.filter(
  map_many(label="is_widget", [col("product")], cells => Scalar::Bool(
    cells[0].as_string() == "widget",
  )),
)
```

`map_elements` is the single-input form: `col("q").map_elements(label="...", s
=> ...)`.

### Sort / group_by / join / drop keys take expressions

The key-bearing verbs now name their keys with `Expr`, so a key can be a derived
column, not just a name. `sort_by` is renamed `sort` to match Polars.

| v0.4 | v0.5 |
|---|---|
| `df.sort_by([("revenue", Desc, NullsLast)])` | `df.sort([(col("revenue"), Desc, NullsLast)])` |
| `df.group_by(["region"])` | `df.group_by([col("region")])` |
| `df.drop(["tmp"])` | `df.drop([col("tmp")])` |
| `df.drop_nulls_in(["revenue"])` | `df.drop_nulls(subset=[col("revenue")])` |
| `lf.sort_by(...)` / `lf.group_by([name])` | `lf.sort(...)` / `lf.group_by([col(name)])` |

`drop_nulls()` with no argument is unchanged (drop every row with a null in any
column); it just gained an optional `subset`, which retired the separate
`drop_nulls_in`. `rename` is unchanged — it takes `Array[(String, String)]`,
matching Polars' dict form, not expressions.

### Join — one method, expression keys

The per-type convenience joins are removed; `join` with a `JoinOptions` is the
only form (as in Polars). Keys are `Expr`, and `left_on` / `right_on` support
differently-named or derived keys.

| v0.4 | v0.5 |
|---|---|
| `left.inner_join(right, ["id"])` | `left.join(right, JoinOptions::on([col("id")]))` — inner is the default `how` |
| `left.left_join(right, ["id"])` | `left.join(right, JoinOptions::on([col("id")]).with_how(Left))` |
| `left.right_join(...)` / `left.outer_join(...)` | `.with_how(Right)` / `.with_how(Outer)` |
| `left.cross_join(right)` | `left.join(right, JoinOptions::cross())` |
| `JoinOptions::on(["id"])` | `JoinOptions::on([col("id")])` |
| *(no equivalent)* | `JoinOptions::left_on([col("a")]).with_right_on([col("b")])` for differently-named keys |

`with_how` / `with_coalesce` / `with_suffix` and the null-key / NaN-key /
suffix semantics are unchanged. One behaviour change: the `coalesce = None`
default now follows Polars per `how` — a `Left` / `Right` join coalesces its
key into one column instead of keeping both (`Inner` already coalesced, `Outer`
still keeps both); pass `with_coalesce(false)` for the old two-column
Left / Right output.

### Row access — `row(i)` returns a tuple, new `rows()`

The rich `RowView` (and the unchecked `row_view`) are removed in favour of
Polars' positional row access. `df.row(i)` now returns an `Array[Scalar]` (by
column order); `df.rows()` returns every row. For a single typed value, read the
column and index it, then narrow the `Scalar`.

| v0.4 | v0.5 |
|---|---|
| `df.row(i)` → `RowView` | `df.row(i)` → `Array[Scalar]` |
| `df.row_view(i)` (unchecked) | *removed* — use `df.row(i)` |
| `df.row_view(i).get_int("c")` | `df.get_column("c").get(i).as_int()` — likewise `as_float` / `as_bool` / `as_string` |
| `df.row(i).get_string("c")` | `df.get_column("c").get(i).as_string()` |
| `view.is_null("c")` | `df.get_column("c").get(i).is_null()` |
| *(iterate rows)* | `df.rows()` → `Array[Array[Scalar]]` |

### Whole-frame reductions — `df.sum()` returns a frame

`df.sum(col)` returning a scalar is removed (it has no Polars analogue). Polars'
two forms take its place: `df[col].sum()` for a single scalar — here
`df.get_column(col).sum()` — and the no-argument `df.sum()` reducing every
numeric column to a **one-row `DataFrame`**.

| v0.4 | v0.5 |
|---|---|
| `df.sum("revenue")` → `Scalar` | `df.get_column("revenue").sum()` → `Scalar` |
| `df.mean("revenue")` → `Double` | `df.get_column("revenue").mean()` → `Double` |
| `df.min("x")` / `df.max("x")` / `df.count("x")` | `df.get_column("x").min()` / `.max()` / `.count()` |
| *(no equivalent)* | `df.sum()` / `mean()` / `min()` / `max()` / `count()` → one-row `DataFrame` |

The frame-wide `df.sum()` reduces only `Int` / `Float` columns (a `Bool` /
`String` column becomes a `Null` cell of its source dtype); `df.count()` is the
per-column non-null count. Note `df.get_column(c).sum()` *raises* on a
non-numeric column, while the frame-wide `df.sum()` tolerates it by nulling — the
two reductions differ on purpose.

### `fill_null` moves to the expression layer

The per-column `df.fill_null(name, value)` is removed; per-column filling is now
an `Expr` method, and there is a new whole-frame form.

| v0.4 | v0.5 |
|---|---|
| `df.fill_null("note", Scalar::String("n/a"))` | `df.with_columns([col("note").fill_null(lit_str("n/a"))])` |
| *(no equivalent)* | `df.fill_null(Scalar::Int(0))` — fills the null cells of every dtype-compatible column |

`Series::fill_null(Scalar)` is unchanged. The `Expr` form is more capable than
the old frame method: the fill value is any `Expr` (a literal, another column for
a coalesce, or a computed tree), and `Int` / `Float` operands unify.

### `replace_column` retired

`replace_column` is removed; `with_columns([lit_series(...)])` replaces a column
by name. Rename the series to the target name first if it differs.

| v0.4 | v0.5 |
|---|---|
| `df.replace_column("x", series)` | `df.with_columns([lit_series(series.rename("x"))])` |

Two behaviour differences, by design: where the old method raised
`ColumnNotFound` on a missing name, `with_columns` *appends* the column; and
where it raised `LengthMismatch` on any off-length series, `lit_series`
broadcasts a length-1 series.

### Naming finalisation

The last non-Polars public names are aligned — mechanical renames, or a fold
into `cast`.

| v0.4 | v0.5 |
|---|---|
| `series.min_value()` / `max_value()` | `series.min()` / `series.max()` |
| `series.take([...])` | `series.gather([...])` |
| `series.unique_count()` | `series.n_unique()` |
| `series.to_int()` / `to_float()` / `to_string_series()` | `series.cast(DataType::Int)` / `cast(DataType::Float)` / `cast(DataType::String)` |
| `series.null_rate()` | *removed* — compute `series.null_count() / series.len()` |
| `series.describe()` | *removed* — wrap in a frame: `DataFrame::new([series]).describe()` |
| `df.get(i, "c")` | `df.item(i, "c")` |
| `@io.format_csv_str(df, opts)` | `@io.format_csv(df, opts)` |

The same `min_value` → `min`, `max_value` → `max`, and `to_int` / `to_float` →
`cast` renames apply to the column backend types (`NumericColumn`,
`BuiltinColumn`, `ColumnStorage`) for callers who reach them directly;
`to_string_column` is kept (it returns a `BuiltinColumn`, not a `cast`'s `Self`).

### `Series` moved to its own package

`Series` was extracted from `frame` into a new `series` package so the
expression layer can build on the per-column unit. Facade users are unaffected —
`@moonframe.Series` still names the same type, with the same constructors and
methods. Only code that imported the type **directly** from `frame` needs the
new path.

| v0.4 | v0.5 |
|---|---|
| `ihb2032/MoonFrame/frame.Series` | `ihb2032/MoonFrame/series.Series` |
| facade `@moonframe.Series` | unchanged |

### Purely additive (no action needed)

v0.5 also *adds*, with nothing to migrate: the `map_elements` / `map_many`
closure escape hatch; the aggregations `std` / `variance` / `median` /
`n_unique` / `first` / `last`; the `Expr` string namespace (`str_to_uppercase` /
`str_contains` / `str_replace` / …); `lit_series` and the `cols` name helper;
`DataFrame::unique()` (first-appearance row dedup); and the lazy file sources
`scan_csv` / `scan_ndjson` (with `_with_options` variants) with projection
pushdown.

### One deliberate non-alignment

`sort` treats a `Float` `NaN` as **missing** (ordered by the tuple's
`NullOrder`), not as a value. This is the only place v0.5 knowingly differs from
Polars, where `NaN` is a value that sorts last independently of `nulls_last`.
Everything else above matches Polars' shape and behaviour.

## v0.3 → v0.4

**Additive — nothing to change.** v0.4 only *adds* symbols: the new `expr`
package (`Expr`, `col` / `lit_*` / `when`, the operators and methods), the new
`lazy` package (`LazyFrame` / `LazyGroupBy`, `lazy_frame`), and new `DataFrame`
/ `GroupedDataFrame` methods (`with_columns` / `select_exprs` / `filter_where`
/ `agg_exprs`). No v0.2 / v0.3 type, method, or enum variant changed, so
existing code compiles and behaves identically. The two new public
sub-packages are available directly for callers who want a slice of the
surface, while callers who import only the facade `ihb2032/MoonFrame` get the
same symbols there. (The `Series`-into-its-own-package split, which *will*
touch existing code, is deferred to v0.5.)

## v0.2 → v0.3

v0.3 is a pre-1.0 breaking release. The source-level breaks:

| v0.2 | v0.3 |
|---|---|
| `Series::storage() -> @column.BuiltinColumn` | `-> @column.ColumnStorage`; the `.data()` / `.validity()` reading surface is unchanged, so column-reading call sites still compile. Use `.to_builtin()` when you need the concrete `BuiltinColumn` |
| `Series::new(name, BuiltinColumn)` | `Series::new(name, ColumnStorage)`; pass `ColumnStorage::from_builtin(col)`, or keep `Series::from_builtin(name, col)` (signature unchanged) |
| `pub(all) enum JoinType { Inner; Left; Cross }` | gained `Right` / `Outer`; an exhaustive `match` over `JoinType` must now handle the two new variants |

The CSV / JSON / NDJSON `*ReadOptions` structs also gained `pub(all)` fields
(read resilience — `on_parse_error`, plus CSV's `allow_nonfinite_floats`): a
full struct literal must add them or switch to `::default()`. The defaults
reproduce the prior behaviour exactly.

## v0.1 → v0.2

| v0.1 | v0.2 |
|---|---|
| `@ops.select(df, names)` (free function) | `df.select(names)` (method) |
| `op(df, ...) -> Result[T, DataError]` + `.bind` / `.map` / `.unwrap` | `df.op(...) -> T raise DataError`, chained directly |
| pattern-match `Ok(x)` / `Err(e)` on the result | call directly in a `raise` context, or `Ok(expr) catch { e => Err(e) }` for a `Result` |
| `filter_try(df, row => row.get_int("x").map(v => v > 0))` | `df.filter(row => row.get_int("x") > 0)` |
| `sort_by(df, spec)` / `sort_by_many(df, specs)` | `df.sort_by([(col, order, nulls), ...])` |
| `Series::min()` / `max()` (`Result`-wrapped) | `Series::min_value()` / `max_value()` (total) |
| `@io.to_markdown(df)` | `df.to_markdown()` |
| `import ... @ops` | gone — verbs live on `DataFrame` in `@frame` |

`format_csv_str` / `format_json_records` / `parse_csv_str` / `read_csv` /
`write_csv` and the JSON / NDJSON equivalents are still `io` free functions (the
`read_*` / `write_*` / `parse_*` ones now `raise`; the `format_*` ones are total
and return a `String`).
