TidyView: A Database Engine in Disguise

How CJC-Lang handles data manipulation — and why I couldn’t just use Pandas.

CJC-Lang
data
databases
determinism
architecture
benchmarks
TidyView is CJC-Lang’s data manipulation system. It uses a tidyverse-style API backed by database-engine internals — columnar storage, bitmask filtering, predicate pushdown, and zone maps — to deliver deterministic, auditable data pipelines with zero hidden mutations. Includes worked examples, audit walkthroughs, and a 38-test benchmark suite proving every architectural claim.
Author

Adam Ezzat

Published

April 12, 2026

Modified

April 13, 2026

🕑 ~30 min read
3layers
103operations
0HashMaps
38benchmark tests
TL;DR

TidyView is CJC-Lang’s data manipulation system. It looks like R’s dplyr on the surface, but underneath it’s a database engine: columnar storage, lazy evaluation, bitmask filtering, predicate pushdown, and zone maps. The result is a data pipeline where every intermediate state is inspectable, the original data is never mutated, and the same input always produces the same output — down to the last bit, on every run. (Tested on Windows; cross-platform verification is ongoing.)

The Problem

When I started building CJC-Lang, the core promise was simple: same seed, same output, always. Every part of the language enforces this — ordered collections, Kahan summation, seeded RNG, no FMA in SIMD kernels.

But then I needed to handle data. Filter a table. Group rows. Compute aggregates. Join two datasets.

The obvious move was to reach for an existing DataFrame library. But when I looked at what’s available, I kept running into the same problems:

Hash-based grouping. Most DataFrame libraries — including Pandas and Polars — use hash maps to group rows. Hash maps are fast, but their iteration order may depend on internal hash seeds or execution strategy. Depending on the configuration, grouping the same data twice can produce rows in a different order. That makes bit-identical reproducibility difficult to guarantee.

Floating-point reduction order. Call sum() on a million floats in Pandas. The result depends on the order of accumulation. Add the numbers in a different order and you get different rounding errors. Small differences, but they compound — and they mean “same data, different result” depending on implementation details. (Polars and other libraries may use more sophisticated reduction strategies, but the result can still vary across execution modes or platforms.)

Parallel scheduling. Libraries that use thread pools for parallel execution (Polars uses Rayon, for example) gain significant throughput. But thread scheduling is inherently non-deterministic, which means parallel aggregation can introduce ordering variation between runs — even with identical input. Whether this matters depends on the workload, but for bit-identical reproducibility it’s a concern.

Aliasing mutations. Pandas has an entire warning category (SettingWithCopyWarning) dedicated to the fact that modifying a filtered subset might or might not modify the original DataFrame. Whether it does depends on internal memory layout decisions that are invisible to the user.

None of these are bugs. They’re design trade-offs made by libraries that prioritize speed over reproducibility. But they break CJC-Lang’s core contract. So I built something else.

What TidyView Is

TidyView is a three-layer data manipulation system:

Layer Type What it does
Immutable view TidyView Zero-copy lens: bitmask + projection over shared data
Materialized frame TidyFrame Copy-on-write buffer for mutations
Grouped view GroupedTidyView Group index for aggregation

The API surface is modeled on R’s dplyr — filter, select, mutate, group_by, summarise, arrange, join. If you’ve used the tidyverse, you already know the vocabulary.

But the implementation borrows from a different tradition entirely: analytical databases.

How Data Flows Through a TidyView Pipeline

flowchart TD
    A["CSV / Data Source"] --> B["Base DataFrame<br/><i>immutable, Rc-shared</i>"]
    B --> C["TidyView<br/><i>zero-copy lens</i>"]
    C --> D["filter( )<br/><i>bitmask A — no data copied</i>"]
    D --> E["filter( )<br/><i>bitmask B — AND with A</i>"]
    E --> F["group_by( )<br/><i>BTreeMap index over visible rows</i>"]
    F --> G["summarise( )<br/><i>Kahan reduction per group</i>"]
    G --> H["Materialized Result<br/><i>TidyFrame — new data</i>"]

    style A fill:#f9f9f9,stroke:#333
    style B fill:#e8f4fd,stroke:#2196F3,stroke-width:2px
    style C fill:#e8f4fd,stroke:#2196F3
    style D fill:#fff3e0,stroke:#FF9800
    style E fill:#fff3e0,stroke:#FF9800
    style F fill:#f3e5f5,stroke:#9C27B0
    style G fill:#f3e5f5,stroke:#9C27B0
    style H fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px

Every step from the base DataFrame through the filters shares the same underlying data. New memory is only allocated for bitmasks (orange) and the final materialized result (green). The original data (blue) is never modified.

The Database Connection

I didn’t set out to build a database engine. But the constraints I was working under — determinism, auditability, memory safety — kept pushing me toward the same solutions that database engineers arrived at decades ago.

Columnar Storage

Data is stored column-major:

// Internally:
columns: [
    ("name",    Str(["Alice", "Bob", "Carol", "Dave"])),
    ("age",     Int([30, 25, 35, 28])),
    ("revenue", Float([150.0, 200.0, 175.0, 225.0])),
]

Each column is a contiguous typed buffer. When you compute sum(revenue), the runtime scans a single Vec<f64> — no pointer chasing, no skipping over unrelated fields. This is the same storage model used by Apache Arrow, DuckDB, and Parquet.

The alternative — row-major storage — would scatter each revenue value across a row struct alongside name and age data. For a table with 100 columns, computing a single-column aggregate would touch 100x more memory than necessary.

Bitmask Filtering

This is the design choice that makes everything else work. When you filter a TidyView, no data is copied. Instead, a bitmask records which rows passed the predicate:

// Original data: 4 rows
base: ["Alice", "Bob", "Carol", "Dave"]  // ages: 30, 25, 35, 28

// After filter(age > 28):
mask: [1, 0, 1, 1]  // Alice (30), Carol (35), Dave (28 fails, hypothetical: > 28)

In practice the mask is packed into 64-bit words — one bit per row. A million-row filter costs 122 KB of bitmask memory. The original data stays exactly where it was, untouched.

Chain three filters together? The bitmasks are ANDed. Still no data copied. Still 122 KB.

let sales: Any = read_csv("sales.csv");
let df: Any = sales
    |> filter(col("region") == "West")
    |> filter(col("revenue") > 1000.0)
    |> filter(col("year") == 2025);

Each filter call returns a new TidyView with a new bitmask. All three views share the same underlying data. The CSV is loaded once. This is the same technique used by bitmap indexes in Oracle and selection vectors in DuckDB.

Predicate Pushdown

TidyView has a lazy evaluation layer where operations build a logical plan tree before executing. The optimizer rewrites this tree using three classic database passes:

Pass 1 — Filter merging. Consecutive filters are combined into a single filter with an AND predicate.

Pass 2 — Predicate pushdown. Filters are pushed as close to the data source as possible.

Pass 3 — Redundant select elimination. Select nodes that project all columns are removed.

Here’s why this matters. Suppose you write:

let result: Any = df
    |> mutate("bonus", col("salary") * 0.1)
    |> filter(col("department") == "Engineering");

Without optimization, this computes bonus for every row, then filters. With predicate pushdown, the optimizer recognizes that department == "Engineering" doesn’t depend on the bonus column, so it pushes the filter below the mutate. Now filtering happens first, and bonus is only computed for engineering rows.

The pushdown rules follow standard relational algebra:

  • Push past select — always safe
  • Push past arrange — filtering doesn’t affect sort order
  • Push past mutate — only if the predicate doesn’t reference a mutated column
  • Push into join branches — if the predicate references only one side
  • Never past group_by + summarise — aggregation changes row identity
  • Never past distinct — uniqueness changes row identity

These are the same rules taught in database systems textbooks. They apply here because TidyView’s operation set is relational algebra — just with tidyverse names.

Zone Maps

Each column tracks lightweight statistics:

// ColumnStats for the "revenue" column:
min: 150.0
max: 225.0
sorted_ascending: false
n_distinct: 4

When you write filter(col("revenue") > 500.0), the zone map says: max is 225, so no row can satisfy > 500. The entire filter can be skipped.

When a column is sorted, the system uses binary search instead of a linear scan — O(log N) instead of O(N).

These are the same optimizations that Vertica, Redshift, and Parquet use at the storage layer. In TidyView they operate at the column level because the entire “database” fits in memory.

What This Prevents

The architectural choices above aren’t just about performance. They prevent specific classes of bugs that plague traditional DataFrame workflows.

The Aliasing Problem

In Pandas:

df = pd.DataFrame({"x": [1, 2, 3], "y": [4, 5, 6]})
subset = df[df.x > 1]
subset["z"] = 99      # Does this modify df? Maybe.

Whether subset["z"] = 99 modifies the original df depends on whether Pandas chose to return a view or a copy when you wrote df[df.x > 1]. This is the SettingWithCopyWarning — one of the most common sources of confusion in data science code.

In TidyView, this problem doesn’t exist. The base data lives behind an Rc (reference-counted pointer) and is never modified. When you need to mutate, TidyFrame checks the reference count:

  • If only one reference exists, mutate in place
  • If multiple references exist, deep-clone first, then mutate the clone

The check is deterministic — it’s a simple integer comparison, not a heuristic. The other views are never affected.

The Order-Dependent Mutation Problem

In Pandas:

df = df.assign(a = df.x + 1, b = df.a * 2)

Does df.a in the second expression see the newly computed a or the original a? In Pandas, it sees the new one. The result depends on the order of the assignments. Swap a and b and you get different data.

In TidyView, mutate uses snapshot semantics: all column references are validated against the column list frozen before any assignments execute. Every expression sees the data as it was when the mutate call started:

let result: Any = df
    |> mutate("a", col("x") + 1)
    |> mutate("b", col("a") * 2);
// col("a") always reads the ORIGINAL "a" column
// If "a" didn't exist before, this is an error — not a silent dependency

This is the same principle as SQL’s UPDATE SET a = b, b = a — the SET clause reads from a snapshot, so this correctly swaps the values rather than setting both to b.

The Hidden Null Problem

Pandas DataFrames can have NaN or None in any column at any time. You discover this at runtime, usually when an aggregation silently skips missing values and gives you a number that’s subtly wrong.

In TidyView, the base Column type has no null representation:

// Column — always fully populated:
Int(Vec<i64>)
Float(Vec<f64>)
Str(Vec<String>)
Bool(Vec<bool>)

There’s no Option wrapper. No sentinel values. If you have a TidyView, its data is guaranteed null-free by type.

Nulls exist, but they’re quarantined in a separate type called NullCol. You only encounter NullCol through operations that can introduce nulls — left joins (unmatched rows), pivot_wider (missing combinations), full joins. These operations return a NullableFrame instead of a TidyFrame, and the type boundary makes the null-possibility explicit.

The question “does my data have nulls?” is answered by the type, not by scanning every cell.

The Floating-Point Drift Problem

# Pandas
pd.Series([1e16, 1.0, -1e16]).sum()  # → 0.0 (wrong)

The mathematically correct answer is 1.0. Pandas gives 0.0 because it uses naive left-to-right summation: 1e16 + 1.0 rounds to 1e16 (the 1.0 is lost to floating-point precision), then 1e16 - 1e16 = 0.0.

TidyView uses Kahan compensated summation for every floating-point reduction — sum, mean, variance, sd, all of them. Kahan summation tracks a correction term that captures the rounding error:

// Kahan summation:
//   sum = 0, c = 0
//   y = 1e16 - 0 = 1e16;    t = 0 + 1e16 = 1e16;    c = (1e16 - 1e16) - 1e16 = 0
//   y = 1.0 - 0 = 1.0;      t = 1e16 + 1.0 = 1e16;   c = (1e16 - 1e16) - 1.0 = -1.0
//   y = -1e16 - (-1.0);      ...compensates the lost 1.0
//   Result: 1.0 (correct)

Same input, same result, every time. Not approximately correct — exactly correct within the limits of double precision.

TidyView in Practice

The architecture above is interesting in the abstract, but what does it actually look like when you write code? Here’s a walkthrough of real CJC-Lang programs using TidyView, showing how operations chain together and how you can audit every step.

Loading and Filtering

let csv = "name,age,city\nAlice,30,NYC\nBob,25,LA\nCharlie,35,NYC\nDiana,28,LA\n";
let df = Csv.parse(csv);
let view = df.view();

// filter: age > 28 (keeps Alice=30, Charlie=35)
let older = view.filter(dexpr_binop(">", col("age"), 28));
print(older.nrows());   // → 2

Csv.parse() returns a DataFrame. .view() wraps it in a TidyView — a zero-copy lens over the data. The filter call doesn’t copy any rows. It creates a new bitmask: [1, 0, 1, 0]. The original four rows are still sitting in the same memory, untouched.

Chaining Operations

TidyView operations return new views or frames, so they chain naturally. Each step narrows, reshapes, or aggregates the data — and every intermediate result is a value you can bind to a variable and inspect later.

let csv = "dept,name,salary\nEng,Alice,100\nEng,Bob,120\nSales,Carol,80\nSales,Dave,90\nEng,Eve,110\n";
let df = Csv.parse(csv);
let view = df.view();

// Chain: filter → group → summarise
let result = view
    .filter(dexpr_binop(">", col("salary"), 85))
    .group_by(["dept"])
    .summarise("n", tidy_count(), "avg", tidy_mean("salary"));
print(result.nrows());   // → 2 (Eng and Sales groups)

This is a three-step pipeline: filter rows where salary exceeds 85, group surviving rows by department, then compute count and mean per group. The filter produces a bitmask; the group-by reads through it to build its index; the summarise collapses each group into one row.

Here’s a longer chain that shows more of the verb vocabulary:

let orders = Csv.parse("product,region,qty,price\nWidget,West,10,25.0\nGadget,East,5,40.0\nWidget,East,8,25.0\nGizmo,West,3,100.0\nWidget,West,12,25.0\nGadget,West,7,40.0\n");
let view = orders.view();

// Chain: filter → mutate → arrange → select
let report = view
    .filter(dexpr_binop("==", col("region"), "West"))   // keep West only
    .mutate("revenue", dexpr_binop("*", col("qty"), col("price")))  // add revenue column
    .view()                                               // back to immutable view
    .arrange([desc("revenue")])                           // sort by revenue descending
    .select(["product", "revenue"]);                      // keep only these columns

// Result: Gizmo/300.0, Widget/300.0, Widget/250.0, Gadget/280.0
// Original `orders` still has all 6 rows, all 4 columns, untouched

Each verb in the chain returns a new value — a TidyView, a TidyFrame, or a GroupedTidyView. The types guide what you can do next: you can’t call summarise unless you’ve called group_by first, and mutate returns a TidyFrame (because it creates new data) while filter and select return TidyView (because they only narrow what’s visible).

Grouped Aggregation with Multiple Statistics

let sales = Csv.parse("region,product,revenue\nWest,Widget,150\nEast,Gadget,200\nWest,Widget,300\nEast,Gadget,225\nWest,Gizmo,100\nEast,Widget,175\n");
let view = sales.view();

let summary = view
    .group_by(["region"])
    .summarise(
        "n",         tidy_count(),
        "total_rev", tidy_sum("revenue"),
        "avg_rev",   tidy_mean("revenue"),
        "min_rev",   tidy_min("revenue"),
        "max_rev",   tidy_max("revenue")
    );
// Groups appear in first-occurrence order: West first, then East
// West: n=3, total=550, avg=183.33, min=100, max=300
// East: n=3, total=600, avg=200.00, min=175, max=225

Group order is always first-occurrence — the group that appears first in the data appears first in the result. Not alphabetical, not hash-dependent. Deterministic.

Adding Computed Columns

let mutated = view.mutate("bonus", dexpr_binop("*", col("salary"), 10));
print(mutated.ncols());   // → 4 (dept, name, salary, bonus)

mutate returns a TidyFrame — a materialized copy with the new column appended. The original view still has three columns. This is the copy-on-write boundary: reads are free, writes trigger a copy.

Sorting and Lazy Permutation

let csv = "name,score\nAlice,85\nBob,92\nCharlie,78\nDiana,95\n";
let df = Csv.parse(csv);
let sorted = df.view().arrange([desc("score")]);
let top2 = sorted.slice_head(2);
print(top2.nrows());   // → 2 (Diana=95, Bob=92)

arrange computes a permutation vector but does not materialize a new DataFrame. The sorted order is stored lazily — operations like select, rename, and relocate pass the permutation through without resolving it. Only when you need physical data (e.g., materialize, filter, slice, group_by) does the sort actually reorder the rows. This means arrange followed by slice_head is significantly faster than sorting the entire dataset.

Joins

let left = Csv.parse("id,name\n10,Alice\n20,Bob\n30,Charlie\n").view();
let right = Csv.parse("id,dept\n10,Eng\n30,Sales\n40,HR\n").view();

let joined = left.inner_join(right, "id", "id");
print(joined.nrows());   // → 2 (id=10 and id=30 match)

Inner join keeps only rows where the key exists in both tables. Alice (id=10) matches Eng, Charlie (id=30) matches Sales. Bob (id=20) and HR (id=40) are dropped.

TidyView supports seven join types: inner_join, left_join, semi_join, anti_join, full_join, cross_join, and natural_join. All use BTreeMap-based lookups with borrowed keys — no per-row string allocation.

Reshaping

// pivot_longer: columns → rows
let wide = Csv.parse("name,math,english\nAlice,90,85\nBob,78,92\n");
let long = wide.view().pivot_longer(
    ["math", "english"],   // columns to pivot
    "subject",             // new name column
    "score"                // new value column
);
// Result: 4 rows (Alice/math/90, Alice/english/85, Bob/math/78, Bob/english/92)

// pivot_wider: rows → columns
let summary = long.view().pivot_wider("subject", "score");
// Back to: Alice/90/85, Bob/78/92

Distinct and Sampling

// distinct: unique rows by column subset
let unique_regions = view.distinct(["region"]);

// deterministic sampling: same seed → same rows, every time
let sample = view.slice_sample(100, seed: 42);

slice_sample uses a seeded LCG-based Fisher-Yates shuffle. Same seed, same 100 rows, every run. (Cross-platform determinism has been verified on Windows; Linux and macOS testing is ongoing.)

How to Audit a TidyView Pipeline

This is where the architecture pays off in practice. When something goes wrong in a data pipeline, the question is always: where did the data go wrong?

In TidyView, every intermediate step is inspectable because the original data is never modified. Every filter produces a bitmask you can examine. Every group_by produces a group index with key values and row indices. And the original data stays intact behind a shared reference, so you can always go back and check it.

Inspection Methods

At any point in a pipeline, you can call:

view.mask()           // → the bitmask — which rows are visible
view.proj()           // → the projection map — which columns are visible
grouped.group_index() // → group keys + row indices per group

From the mask:

  • mask.iter_set() gives the exact indices of visible rows
  • mask.count_ones() gives the count of surviving rows

From the projection:

  • proj.indices() gives which column indices are visible

The original data is always there behind the Rc<DataFrame> — no operation destroys it.

A Worked Example

Suppose you’re analyzing quarterly sales data and you get a result that looks wrong. In a traditional DataFrame library, you’d re-run the pipeline with print statements and hope to catch the problem. In TidyView, you can inspect every intermediate state because nothing was destroyed.

Here’s a concrete scenario. You have eight sales records and you want total revenue by product in the West region:

let sales = Csv.parse("product,region,revenue\nWidget,West,150\nGadget,West,200\nWidget,East,175\nGadget,East,225\nWidget,West,300\nGizmo,East,50\nGadget,West,180\nGizmo,East,75\n");
let view = sales.view();

let west = view.filter(dexpr_binop("==", col("region"), "West"));
let grouped = west.group_by(["product"]);
let summary = grouped.summarise("total_rev", tidy_sum("revenue"));

The summary shows Widget: 450, Gadget: 380. Is that right? Let’s audit backward.

Step 1: Which rows survived the filter?

The bitmask after filter(region == "West") has bits set at positions [0, 1, 4, 6]. That’s four rows out of eight. You can verify: row 0 is Widget/West/150, row 1 is Gadget/West/200, row 4 is Widget/West/300, row 6 is Gadget/West/180. All West. None of the East rows slipped through.

Step 2: How did the groups form?

The group index shows two groups in first-occurrence order:

  • Group “Widget” → base-frame rows [0, 4]
  • Group “Gadget” → base-frame rows [1, 6]

Notice: Gizmo isn’t here. Both Gizmo rows were East, so they were filtered out before grouping. If you expected Gizmo to appear, the bitmask tells you exactly why it didn’t — rows 5 and 7 have their bits cleared.

Step 3: Verify the aggregation

Widget revenue: row 0 (150) + row 4 (300) = 450. Gadget revenue: row 1 (200) + row 6 (180) = 380. Both use Kahan compensated summation, so even with millions of rows the result would be bit-identical across runs.

Step 4: Is the original data intact?

Yes. view.nrows() still returns 8. You can re-filter, re-group, or inspect any column at any time. If the final number is wrong, you trace backward through bitmasks and group indices until you find where your understanding diverged from reality.

This is fundamentally different from a system where df = df[df.region == "West"] discards the filtered-out rows. In that system, if the filter was wrong, the evidence is gone. In TidyView, the evidence is the bitmask, and the original data is still there to check against.

Catching Mistakes at Compile Time

TidyView’s type system and error reporting catch common mistakes before they become wrong numbers:

Typo in a column name:

view.filter(dexpr_binop(">", col("revnue"), 100));
// → Error: column `revnue` not found

Self-referencing mutate:

view.mutate("bonus", dexpr_binop("*", col("salary"), 0.1))
    .mutate("double_bonus", dexpr_binop("*", col("bonus"), 2));

The second mutate sees bonus because it’s a separate call on the result of the first. But if you tried to reference a column in the same mutate call that creates it:

view.mutate("a", dexpr_binop("+", col("x"), 1),
            "b", dexpr_binop("*", col("a"), 2));
// → Error: column `a` not found

This is snapshot semantics — all column references are validated against the column list frozen before any assignments. You can’t accidentally create an order-dependent pipeline within a single operation.

Type mismatch:

view.filter(dexpr_binop("+", col("name"), 1));
// → Error: type mismatch — expected Bool predicate, got Int

The filter predicate must evaluate to Bool. Arithmetic on a string column, or a predicate that returns a number instead of true/false, is caught immediately with an error that names the exact types involved.

Error Messages That Point to the Problem

When something goes wrong, TidyView’s error types name the exact issue:

ColumnNotFound("revnue")       // Typo — you meant "revenue"
DuplicateColumn("total")       // Two columns with the same name in select
PredicateNotBool { got: "Int" } // filter predicate returned Int, not Bool
TypeMismatch { expected: "Float", got: "Str" }  // arithmetic on a string column
LengthMismatch { expected: 1000, got: 500 }     // broadcast size mismatch
EmptyGroup                     // first() or last() called on a group with zero rows
CapacityExceeded { limit: 65535, got: 70000 }   // too many distinct levels for a factor column

Every error variant names the specific column, type, or value that caused the problem. You know what went wrong and where, not just that something failed.

EmptyGroup catches a subtle bug: if an aggressive filter removes all rows from a group and you then call first() or last() in your summarise, you get a clear error instead of a silent null or a crash. CapacityExceeded guards the factor column type — CJC-Lang’s factor columns use u16 codes (max 65,535 distinct levels) for cache efficiency, and this error tells you exactly when your data exceeds that budget.

Why Not Just Use Polars?

Polars is a good library. It’s fast, it has a lazy API, and it uses Arrow-backed columnar storage. If I were building a Python application, I’d probably use it.

But Polars makes different trade-offs than TidyView:

Polars TidyView
Grouping Hash-based (fast; order may vary by execution mode) BTreeMap (deterministic first-occurrence order)
Summation Optimized reduction (fast; may vary across platforms) Kahan compensated (deterministic, bit-identical)
Parallelism Rayon thread pool (high throughput) Single-threaded (deterministic, no scheduling variance)
Null model Nullable everywhere (Arrow validity bitmaps) Null-free by default, NullCol only where needed
Sampling System RNG Seeded LCG-based Fisher-Yates

None of Polars’ trade-offs are wrong — and Polars is significantly faster for most workloads. It’s a production-grade library optimized for throughput. TidyView is optimizing for a different property: reproducibility — the guarantee that the same pipeline on the same data always produces the same result, bit for bit, across repeated runs. (All benchmarks and determinism tests were run on Windows; cross-platform verification is ongoing.) TidyView will be slower than Polars on almost any benchmark. That’s the expected cost of determinism.

When your work requires an audit trail — when a regulator, a reviewer, or future-you needs to verify that a result is correct — that guarantee can matter more than raw speed.

Benchmark Results

The claims above — bitmask filtering is cheap, chained filters don’t copy data, determinism holds across runs — are testable. I wrote a 38-test benchmark suite that measures TidyView’s performance and verifies its architectural guarantees. Here are the results, run on my machine (Intel i7-11390H, Windows, Rust 1.91 release mode, 100K-row datasets).

A note on context. These benchmarks measure TidyView against itself — lazy vs. eager, scaling behavior, determinism proofs. They are not comparative benchmarks against Polars, DuckDB, or Pandas. TidyView is single-threaded and prioritizes determinism over throughput; it will be slower than production-optimized libraries on raw performance. The goal here is to verify that TidyView’s architectural claims hold, not to claim speed parity with tools that make different trade-offs.

A. Filter Microbenchmarks

Test Lazy (µs) Eager (µs) Ratio
Int filter, 100K rows 165 11,943 72×
Float filter, 100K rows 191 11,804 62×
String filter, 100K rows 352 6,255 18×
Int filter, 1M rows 2,002 157,135 79×

What this proves: Lazy bitmask filtering is 62–79× faster than eager materialization for numeric columns. The filter itself just scans one column and sets bits — no row copying, no allocation beyond the bitmask.

String filters benefit from a columnar fast path that evaluates the predicate directly against the column buffer (bitwise, not row-by-row) — bringing them to 352 µs, an 18× lazy/eager ratio.

The 1M-row int filter takes about 2 ms to produce a bitmask. Materializing — actually copying the surviving rows into a new DataFrame — takes 157 ms. If you’re chaining multiple filters, you save that materialization cost at every step.

B. Chained Filter Composition

Test Time (µs)
Single int filter, 100K 220
Two chained int filters, 100K 384
Int + string chain, 100K 609
Two string filters, 100K 813

What this proves: Chaining two filters costs roughly the sum of the individual filters, not more. The second filter reads through the first filter’s bitmask — it only examines rows where the bit is set. No intermediate DataFrame is created. The cost of the chain is dominated by the most expensive predicate type (string comparison).

C. Group-By + Summarise

Test Groups Time (µs)
Count, 4 groups, 100K 4 3,099
Mixed agg (5 stats), 4 groups, 100K 4 5,918
Count + Sum, 100 groups, 100K 100 10,734
Count, 10K groups, 100K 10,000 42,778
Count + Sum + Mean, 10K groups, 100K 10,000 39,782

What this proves: Group-by cost scales with the number of distinct groups, not just row count. With 4 groups (the region column), grouping 100K rows takes about 3 ms. With 10K groups, it takes 40 ms. This is the cost of the BTreeMap-based index — O(N log G) where G is the number of groups. Adding more aggregation functions adds relatively little overhead; the dominant cost is building the group index, not computing the aggregates.

The single-key fast path (used when grouping by one column) avoids per-row heap allocation entirely: the BTreeMap key is a borrowed reference into the column data, not a cloned value. For the 4-group case, this means 100K lookups with zero string copies.

D. Sorting

Test Time (µs)
Float descending, 100K 1,378
Int ascending, 100K 1,201
String ascending, 100K 18,343
Multi-key (string + float), 100K 41,638

What this proves: Numeric sorts on 100K rows complete in about 1.2–1.4 ms thanks to pre-resolved column references — the sort comparator holds a direct &Column pointer instead of doing a name lookup on every comparison. String sorting is roughly 13× more expensive than numeric sorting due to comparison cost. Multi-key sorting adds the cost of a secondary comparator.

All sorts use Rust’s stable sort (sort_by), which preserves the relative order of equal elements — important for determinism. The result is stored as a lazy permutation vector, not a new DataFrame. Operations like select, rename, and relocate propagate the permutation without resolving it.

E. Joins

Test Time (µs)
Inner join, 100K × 100 33,936
Inner join, 100K × 1K 40,507
Left join, 100K × 100 38,712

What this proves: Join performance is dominated by the left table size. Increasing the right table from 100 to 1,000 rows adds about 20% to the join time, because the right table is indexed and lookups are O(log K) where K is the number of unique keys.

Both the right-side index build and the left-side probe use borrowed keys — ColumnKeyRef references directly into the column data without cloning. For single-key joins (the common case), the BTreeMap key is a single borrowed value with no Vec wrapper, eliminating per-row allocation on both sides.

F. Full Pipeline Benchmarks

Pipeline Time (µs)
filter → group → summarise → arrange, 100K 4,151
filter → mutate → group → summarise, 100K 40,640
join → filter → group → summarise, 100K 97,769

What this proves: Realistic multi-step pipelines compose predictably. The filter-group-summarise-arrange pipeline (4.2 ms) is faster than the sum of its parts because the filter reduces the number of rows that downstream operations process, and the lazy sort avoids materializing a new DataFrame. The join-based pipeline is slowest because joins are the most expensive operation and produce a new materialized DataFrame.

G. Scaling Behavior

Row scaling (filter):

Rows Filter Time (µs)
10K 17
100K 149
1M 2,345

Filter time scales linearly with row count — exactly what you’d expect from a single-pass scan. 10× more rows ≈ 10× more time.

Row scaling (group-by + summarise):

Rows Time (µs)
10K 384
100K 4,065
1M 65,106

Group-by also scales linearly, but with a higher constant factor due to the BTreeMap insertions. The single-key fast path keeps this manageable: 1M rows grouped in 65 ms.

Column width scaling (100K rows):

Columns Filter (µs) Materialize (µs)
3 183 957
11 153 2,520
51 187 20,388
101 247 36,450

What this proves: Filter time is nearly constant regardless of table width — because filtering only touches the predicate column, not the others. Materialization scales linearly with column count because it has to copy every column. This is the columnar storage payoff: a table with 101 columns filters as fast as a table with 3 columns.

Group cardinality scaling (100K rows):

Groups Time (µs)
4 7,914
100 17,814
1,000 29,940
10,000 42,220
50,000 58,239

Going from 4 groups to 50,000 groups increases time by about 7.4×. The BTreeMap index is O(N log G), so the cost grows sub-linearly with group count.

H. Determinism Verification

Test Result
Pipeline (sum + mean + sd + median), 10 runs Bit-identical
Sort stability (equal keys preserve insertion order) Stable
Group order (first-occurrence across 5 runs) Deterministic

What this proves: The determinism guarantee isn’t aspirational — it’s measured. Ten runs of the same pipeline produce identical floating-point results down to the last bit. The sort is stable (equal elements preserve their original order), and groups always appear in first-occurrence order. Additionally, five proptest property suites (200 random cases each) verify these invariants hold across random inputs.

I. Memory Budget

Test Result
1M-row bitmask 122 KB (vs 15,625 KB data = 128× smaller)
3 chained filters, 100K rows Each mask: 1,563 words. Rows: 74,999 → 49,999 → 24,999

What this proves: A bitmask for 1 million rows costs 122 KB — one bit per row, packed into 64-bit words. The data itself (just the id and value columns) occupies 15.6 MB. The bitmask is 128× smaller than the data it describes. Three chained filters create three separate bitmasks, each 1,563 words (12.5 KB), while the underlying data is shared. The row counts shrink progressively (75K → 50K → 25K) as each filter narrows the result, but no data is copied until you explicitly materialize.

J. Trust and Audit Trail

These are not performance benchmarks — they’re correctness proofs that verify TidyView’s architectural claims.

Test What it verifies
Filter traceability After two filters on a 10-row dataset, the bitmask contains exactly [0, 4, 8]. You can name every surviving row by index.
Group membership Groups appear in first-occurrence order (Eng, Sales, HR — not alphabetical). Each group’s row indices are inspectable: Eng = [0, 2], Sales = [1, 4], HR = [3, 5].
Snapshot semantics mutate("a", ..., "b", col("a") * 2) fails with column 'a' not found — you can’t reference a column created in the same mutate call.
Copy-on-write isolation Clone a frame, mutate the clone — the original has 2 columns, the clone has 3. They’re independent.
Full pipeline trace Filter → group → summarise on 8 rows: west_rows = [0, 1, 4, 6], Widget total = 450, Gadget total = 380. Original still has 8 rows.
Deterministic sampling slice_sample(100, seed=42) selects the same 100 rows across 5 runs.
Projection inspection select(["b", "d"]) on a 4-column table sets projection indices to [1, 3]. Original table still reports 4 columns.

What this proves: Every architectural claim made earlier in this post — bitmask traceability, group ordering, snapshot semantics, copy-on-write isolation, deterministic sampling — is verified by a concrete test with concrete numbers.

The One-Sentence Summary

TidyView is a database engine wearing a tidyverse costume, designed so that same data + same code = same result on every run, every time — verified on Windows, with cross-platform testing still ahead.

It combines familiar dplyr verbs (filter, select, mutate, group_by, summarise) with database-engine internals (columnar storage, bitmask filtering, predicate pushdown, zone maps) to deliver data pipelines that are deterministic by construction, auditable by design, and memory-safe without runtime surprises.

CJC-Lang is open source and available on crates.io. The TidyView implementation lives in crates/cjc-data/ — about 14,000 lines of Rust with zero external dependencies. The full benchmark suite (38 tests, 10 categories) is in tests/tidyview_benchmarks.rs.