diff --git a/.claude/hooks/ask-before-risky-commands.sh b/.claude/hooks/ask-before-risky-commands.sh
new file mode 100755
index 0000000..4f8788c
--- /dev/null
+++ b/.claude/hooks/ask-before-risky-commands.sh
@@ -0,0 +1,69 @@
+#!/usr/bin/env bash
+# Claude Code PreToolUse(Bash) gate — pathscale backend service.
+#
+# Prompts before prod-affecting / destructive commands in ANY wrapper form
+# (env VAR=val …, tool -C
…, chained with ; && |, multi-line, quoted via
+# bash -c '…'). For everything else it stays SILENT (exit 0, no decision), so the
+# normal permission rules — permissions.allow / ask / deny in .claude/settings.json
+# and the session's permission mode — decide as usual.
+#
+# This is one layer of defense, not a replacement for the permission system: a
+# pattern match over a command string is best-effort (quoting and indirection can
+# evade any blocklist). It backs up the declarative permissions.ask list in
+# .claude/settings.json — KEEP THE TWO IN SYNC — and for fully autonomous runs,
+# prefer OS-level sandboxing on top.
+#
+# Adapted from PakhomovAlexander/project-hub. Edit RISKY_WORDS for this repo;
+# tooling; further branches below gate `git`/`docker push`, `git clean`, recursive
+# `rm`, `find -delete`, package publishing, PR-merge/release via `gh`, and a deploy
+# script run by path. Add your own command families (e.g. `ssh`, a bespoke deploy
+# CLI) to RISKY_WORDS, or trim what you don't use — and mirror the change in
+# permissions.ask in .claude/settings.json.
+set -u
+
+# --- the watchlist: command words that should prompt before running ----------------
+RISKY_WORDS="aws|gcloud|az|kubectl|helm|terraform|terragrunt|flyctl|fly"
+# -----------------------------------------------------------------------------------
+
+# Pull the command out of the hook's stdin JSON (jq if available, else python3).
+if command -v jq >/dev/null 2>&1; then
+ cmd="$(jq -r '.tool_input.command // ""' 2>/dev/null)"
+else
+ cmd="$(python3 -c 'import sys, json; print(json.load(sys.stdin).get("tool_input", {}).get("command", ""))' 2>/dev/null)"
+fi
+
+# Couldn't read the command → stay neutral, let normal permission rules decide.
+[ -z "$cmd" ] && exit 0
+
+# A command word counts as "at a command position" after start-of-line, whitespace,
+# ; & | ( or a quote — and ends before whitespace, a quote, ) ; & | or end-of-line —
+# so `bash -c 'git push'` is still seen.
+b="(^|[[:space:];&|(\"'\`])"
+e="([[:space:];&|)\"'\`]|$)"
+# Global options that may sit between a tool and its subcommand (git -C dir push).
+opts='([[:space:]]+(-[A-Za-z-]+|[-_A-Za-z0-9]+=[^[:space:]]+|-C[[:space:]]+[^[:space:]]+))*'
+
+re="${b}(${RISKY_WORDS})${e}"
+# git push / git clean, docker push — allowing global options before the subcommand.
+re="$re|${b}git${opts}[[:space:]]+(push|clean)${e}"
+re="$re|${b}docker${opts}[[:space:]]+push${e}"
+# recursive rm (-r / -R / -fr / --recursive), with flags/paths in any order.
+re="$re|${b}rm([[:space:]]+[^[:space:]]+)*[[:space:]]+(-[A-Za-z]*[rR]|--recursive)"
+# find … -delete — irreversible bulk delete.
+re="$re|${b}find([[:space:]]+[^[:space:]]+)*[[:space:]]+-delete${e}"
+# package publishing — ships artifacts to a registry.
+re="$re|${b}(npm|pnpm|yarn|bun|cargo|gem)([[:space:]]+[^[:space:]]+)*[[:space:]]+publish${e}"
+re="$re|${b}twine([[:space:]]+[^[:space:]]+)*[[:space:]]+upload${e}"
+# gh mutations that merge, ship, or destroy.
+re="$re|${b}gh[[:space:]]+(pr[[:space:]]+merge|repo[[:space:]]+delete|release[[:space:]]+(create|delete))${e}"
+re="$re|${b}gh[[:space:]]+api([[:space:]]+[^[:space:]]+)*[[:space:]]+(-X|--method)[[:space:]]+(POST|PUT|PATCH|DELETE)${e}"
+# a deploy script invoked by path, e.g. ./scripts/deploy.sh — a word-list can't see it.
+re="$re|${b}([./A-Za-z0-9_-]*/)?deploy(\.[A-Za-z]+)?${e}"
+# WorkTable data migrations and endpoint regeneration rewrite committed artifacts.
+re="$re|${b}([./A-Za-z0-9_-]*/)?regenerate_endpoints(\.[A-Za-z]+)?${e}"
+
+if printf '%s\n' "$cmd" | grep -Eq "$re"; then
+ printf '%s' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"Prod-affecting / destructive command — confirm before running."}}'
+fi
+# No match → no output: fall through to the normal permission flow.
+exit 0
diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 0000000..a7af469
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,44 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(cargo build:*)",
+ "Bash(cargo check:*)",
+ "Bash(cargo test:*)",
+ "Bash(cargo fmt:*)",
+ "Bash(cargo clippy:*)",
+ "Bash(cargo tree:*)",
+ "Bash(git status:*)",
+ "Bash(git diff:*)",
+ "Bash(git log:*)"
+ ],
+ "ask": [
+ "Bash(git push:*)",
+ "Bash(cargo publish:*)",
+ "Bash(npm publish:*)",
+ "Bash(bun publish:*)",
+ "Bash(docker push:*)",
+ "Bash(gh pr merge:*)",
+ "Bash(gh release:*)",
+ "Bash(aws:*)",
+ "Bash(gcloud:*)",
+ "Bash(az:*)",
+ "Bash(kubectl:*)",
+ "Bash(helm:*)",
+ "Bash(terraform:*)",
+ "Bash(flyctl:*)"
+ ]
+ },
+ "hooks": {
+ "PreToolUse": [
+ {
+ "matcher": "Bash",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/ask-before-risky-commands.sh\""
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/.gitignore b/.gitignore
index aa8da64..dddbbb5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,4 +11,4 @@ tests/data/*
!tests/data/expected/
!tests/data/persist_index_table_of_contents.wt.idx
-.claude
+/.claude/settings.local.json
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..b10df2a
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,84 @@
+# Working agreement — WorkTable
+
+The operating contract for **any** coding agent working in this repository. This file is
+the single source of truth for the rules: Codex, Cursor and Gemini CLI read `AGENTS.md`
+natively, and Claude Code loads it through the `@AGENTS.md` import in
+[`CLAUDE.md`](CLAUDE.md). **Never fork these rules into a per-vendor file.**
+
+**Rust crate** (`worktable`).
+
+## Invariants (don't break these)
+
+- **Keep `cargo fmt` and `cargo clippy --all-targets` clean.** Lint failures are part of the build here, not advisory.
+- **Publishing to crates.io is irreversible.** A version number can never be reused, and yanking does not delete. Run `cargo publish --dry-run` first, publish from the merged default branch, and tag the release.
+- **A pre-release version (`-alpha`, `-beta`) needs an exact dependency pin.** A plain `"2.0"` requirement will not match `2.0.0-alpha.1`, so consumers must be bumped deliberately.
+- **Docs describe what is true now.** If you change behaviour, update the README and any affected doc in the same change.
+
+## Build & test
+
+```bash
+cargo build
+cargo test
+cargo fmt && cargo clippy --all-targets # run after every change
+```
+
+## Verification
+
+Run what you build before reporting it done. Type-checks and tests verify code correctness,
+not feature correctness — **if you can't run it, say so explicitly** rather than implying
+success.
+
+- Compare against the base branch rather than asserting: a pre-existing failing test or lint
+ error is not something you introduced, and saying so requires checking.
+- A build that finishes suspiciously fast was cached, not rebuilt. Force a real rebuild when
+ the rebuild is the thing you're verifying.
+
+## PR discipline
+
+**Always paste the full PR URL** (`https://github.com/pathscale/WorkTable/pull/`), not just the number, so it's
+clickable.
+
+
+
+## Keeping docs honest
+
+Hit a factual error here — a stale path, a wrong command, a moved status? Fix it in the same
+change. Don't open cosmetic rewording PRs.
+
+Learned something durable — a gotcha, a decision, a constraint? It belongs **in this repo's
+docs**, not in your agent's private memory. Repo docs are versioned, reviewable, and visible
+to every agent and human; private memory dies with your machine.
+
+## Git workflow
+
+- **Always specify the branch when pushing**: `git push origin branch-name`
+- **Branch naming**: `fix/issue-description` or `feat/issue-description`
+- **No force-pushing** during PR review
+
+## Guardrails
+
+[`.claude/settings.json`](.claude/settings.json) and [`.claude/hooks/`](.claude/hooks/) make
+Claude Code prompt a human before prod-affecting or destructive commands — pushes, publishing
+to a registry, `gh pr merge`, cloud CLIs, recursive deletes, deploy scripts.
+
+**Other agents don't get that net automatically.** Apply the same rule yourself: ask before
+running any command family listed in
+[`.claude/hooks/ask-before-risky-commands.sh`](.claude/hooks/ask-before-risky-commands.sh).
+It is one layer of defence, not a guarantee — a pattern match over a command string is
+best-effort.
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..7d37442
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,12 @@
+@AGENTS.md
+
+# Claude Code notes — WorkTable
+
+The import above is binding: [`AGENTS.md`](AGENTS.md) is the **working agreement** for this
+repository, and every Claude Code session loads it automatically. Don't copy rules here —
+one source of truth, no drift. Only genuinely Claude-specific wiring belongs below.
+
+- Guardrails live in [`.claude/settings.json`](.claude/settings.json): safe read-only
+ commands are pre-allowed; pushes, publishing, `gh pr merge`, cloud CLIs and deploys prompt
+ first (`permissions.ask` plus the `PreToolUse` hook in [`.claude/hooks/`](.claude/hooks/)).
+- Keep the hook's `RISKY_WORDS` and `permissions.ask` **in sync** — they back each other up.
diff --git a/paper-bench/Cargo.toml b/paper-bench/Cargo.toml
new file mode 100644
index 0000000..1718650
--- /dev/null
+++ b/paper-bench/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "wt-paper-bench"
+version = "0.1.0"
+edition = "2024"
+
+# Opt out of the parent WorkTable workspace
+[workspace]
+
+[dependencies]
+worktable = { path = ".." }
+tokio = { version = "1", features = ["full"] }
+parking_lot = "0.12"
+dashmap = "6"
+
+[profile.release]
+lto = "fat"
+codegen-units = 1
diff --git a/paper-bench/README.md b/paper-bench/README.md
new file mode 100644
index 0000000..3153d97
--- /dev/null
+++ b/paper-bench/README.md
@@ -0,0 +1,67 @@
+# wt-paper-bench — benchmark harness for the CIDR 2027 paper
+
+Fills the `\ph{...}` placeholders in the paper. Run **in your macOS terminal**
+(not through Claude's sandbox — it has no crates.io access). For the paper's
+*official* numbers, re-run on a quiet, dedicated Linux x86 box with cores
+pinned; Mac runs are fine for development and go/no-go decisions.
+
+## Run
+
+```bash
+cd ~/code/WorkTable/paper-bench
+mkdir -p results
+
+# 1. Lock-granularity contention matrix (~7 min: 4 modes x 5 task counts x 2 runs x 5s)
+cargo run --release --bin contention | tee results/contention.csv
+
+# 2. Specialization ablation (the make-or-break experiment)
+ROWS=1000000 REPS=5 cargo run --release --bin ablation | tee results/ablation.csv
+
+# 3. Hand-rolled baselines (Vec / RwLock / DashMap)
+ROWS=1000000 REPS=5 cargo run --release --bin baselines | tee results/baselines.csv
+
+# 4. Compile-time + binary-size cost of specialization
+bash scripts/compile_cost.sh | tee results/compile_cost.csv
+```
+
+Knobs: `DURATION_SECS` (contention, default 5), `ROWS` (default 1M),
+`REPS` (default 5, median reported).
+
+When done, just tell Claude the results are in `paper-bench/results/` — they'll
+be staged back, analyzed, and the paper tables/figures filled.
+
+## What maps to what in the paper
+
+| Output | Paper location |
+|---|---|
+| `contention.csv` | §5 lock-granularity ablation + scaling figure (C2) |
+| `ablation.csv` | Table 1 (specialization ablation — C1, the thesis test) |
+| `baselines.csv` | Table 2 rows: Vec/HashMap/DashMap |
+| `compile_cost.csv` | §5 "cost of specialization" sentence |
+
+Still TODO after this round: external baselines (sled, redb, LMDB/heed,
+SQLite `:memory:`) as a follow-up crate, YCSB-shaped mixes, and multi-threaded
+ablation runs.
+
+## Fairness caveats to revisit before trusting Table 1
+
+The dynamic twin (`src/dynamic.rs`) models the dynamism tax as: tagged-value
+rows + runtime catalog lookup + encode/decode per access + coarse per-row
+mutex. It does NOT share WorkTable's page allocator or B-tree (it uses a slot
+vector + BTreeMap, which is *favorable* to the twin for point ops — so a
+specialized win is conservative). A v2 twin reusing WorkTable's pages/indexes
+with only the row representation dynamized would isolate the typing cost more
+precisely. Review `dynamic.rs` before the official campaign.
+
+## Notes
+
+- The bench table (`src/lib.rs`) has an indexed column `a`, non-indexed `b`/`e`
+ (the disjoint contention pair), an `f64`, and a fixed-length `String` (keeps
+ the table on the unsized code path; same-length updates stay in place).
+- `contention` modes: `disjoint` (b vs e — field locks shouldn't collide),
+ `overlap` (both tasks write {b,e} — must serialize), `mutex` (external
+ single lock), `inplace` (closure RMW). The C2 claim is
+ disjoint >> overlap ≈ correct, with mutex as the floor.
+- First build takes a few minutes (workspace + LTO). If `cargo` complains
+ about the parent workspace, the `[workspace]` opt-out table in Cargo.toml
+ should prevent it — report the error if not.
diff --git a/paper-bench/scripts/compile_cost.sh b/paper-bench/scripts/compile_cost.sh
new file mode 100644
index 0000000..09f96a1
--- /dev/null
+++ b/paper-bench/scripts/compile_cost.sh
@@ -0,0 +1,69 @@
+#!/usr/bin/env bash
+# Compile-time and binary-size cost of specialization (paper §5).
+# Builds tiny crates with 1 and 8 worktable! tables, times cold release builds,
+# reports binary sizes. Run from paper-bench/ inside the WorkTable clone.
+set -euo pipefail
+
+WT_PATH="$(cd "$(dirname "$0")/../.." && pwd)" # the WorkTable clone root
+WORK=$(mktemp -d)
+echo "bench,tables,cold_build_secs,binary_bytes"
+
+gen_crate() {
+ local n=$1 dir=$2
+ mkdir -p "$dir/src"
+ cat > "$dir/Cargo.toml" < "$dir/src/main.rs"
+}
+
+for n in 1 8; do
+ dir="$WORK/cost-$n"
+ gen_crate "$n" "$dir"
+ ( cd "$dir" && cargo fetch >/dev/null 2>&1 )
+ start=$(date +%s.%N)
+ ( cd "$dir" && cargo build --release >/dev/null 2>&1 )
+ end=$(date +%s.%N)
+ bin=$(find "$dir/target/release" -maxdepth 1 -type f -perm -111 -name "wt-cost-*" | head -1)
+ size=$(stat -f%z "$bin" 2>/dev/null || stat -c%s "$bin")
+ echo "compile_cost,$n,$(echo "$end $start" | awk '{printf "%.1f", $1-$2}'),$size"
+done
+
+rm -rf "$WORK"
diff --git a/paper-bench/src/bin/ablation.rs b/paper-bench/src/bin/ablation.rs
new file mode 100644
index 0000000..f9f04b6
--- /dev/null
+++ b/paper-bench/src/bin/ablation.rs
@@ -0,0 +1,139 @@
+//! Specialization ablation (paper Table 1): WorkTable vs the dynamic twin.
+//!
+//! Single-threaded per-op cost; ROWS rows; REPS repetitions, median reported.
+//! CSV to stdout: bench,engine,op,ops_per_sec
+
+use wt_paper_bench::dynamic::*;
+use wt_paper_bench::util::*;
+use wt_paper_bench::*;
+
+fn main() {
+ let rows = env_u64("ROWS", 1_000_000);
+ let reps = env_u64("REPS", 5) as usize;
+ println!("bench,engine,op,ops_per_sec");
+
+ // ---------------- specialized ----------------
+ let rt = tokio::runtime::Builder::new_multi_thread()
+ .worker_threads(2)
+ .enable_all()
+ .build()
+ .unwrap();
+
+ // insert
+ let table = BenchWorkTable::default();
+ let ins = median_ops_per_sec(reps, || {
+ // fresh table per rep to avoid unbounded growth distorting later reps
+ let t = BenchWorkTable::default();
+ for v in 0..rows {
+ t.insert(mk_row(&t, v)).unwrap();
+ }
+ rows
+ });
+ println!("ablation,specialized,insert,{ins:.0}");
+
+ // populate once for read/update reps
+ for v in 0..rows {
+ table.insert(mk_row(&table, v)).unwrap();
+ }
+
+ // random point read
+ let rd = median_ops_per_sec(reps, || {
+ let mut rng = Rng::new(42);
+ let n = rows;
+ for _ in 0..n {
+ let pk = rng.below(rows);
+ std::hint::black_box(table.select(pk));
+ }
+ n
+ });
+ println!("ablation,specialized,point_read,{rd:.0}");
+
+ // non-indexed field update (UpdB)
+ let upd = {
+ let table = &table;
+ median_ops_per_sec(reps, || {
+ let mut rng = Rng::new(7);
+ let n = rows / 10;
+ rt.block_on(async {
+ for _ in 0..n {
+ let pk = rng.below(rows);
+ table.update_upd_b(UpdBQuery { b: pk }, pk).await.unwrap();
+ }
+ });
+ n
+ })
+ };
+ println!("ablation,specialized,update_field,{upd:.0}");
+
+ // indexed field update (UpdA) — includes index maintenance
+ let updi = {
+ let table = &table;
+ median_ops_per_sec(reps, || {
+ let mut rng = Rng::new(9);
+ let n = rows / 10;
+ rt.block_on(async {
+ for _ in 0..n {
+ let pk = rng.below(rows);
+ table.update_upd_a(UpdAQuery { a: pk }, pk).await.unwrap();
+ }
+ });
+ n
+ })
+ };
+ println!("ablation,specialized,update_indexed,{updi:.0}");
+
+ // in-place RMW
+ let inp = {
+ let table = &table;
+ median_ops_per_sec(reps, || {
+ let mut rng = Rng::new(11);
+ let n = rows / 10;
+ rt.block_on(async {
+ for _ in 0..n {
+ let pk = rng.below(rows);
+ table.update_inc_b_in_place(|b| *b += 1, pk).await.unwrap();
+ }
+ });
+ n
+ })
+ };
+ println!("ablation,specialized,inplace_rmw,{inp:.0}");
+
+ // ---------------- dynamic twin ----------------
+ let ins = median_ops_per_sec(reps, || {
+ let t = DynTable::new();
+ for _ in 0..rows {
+ let pk = t.get_next_pk();
+ t.insert(mk_dyn_row(pk, pk));
+ }
+ rows
+ });
+ println!("ablation,dynamic,insert,{ins:.0}");
+
+ let dt = DynTable::new();
+ for _ in 0..rows {
+ let pk = dt.get_next_pk();
+ dt.insert(mk_dyn_row(pk, pk));
+ }
+
+ let rd = median_ops_per_sec(reps, || {
+ let mut rng = Rng::new(42);
+ for _ in 0..rows {
+ let pk = rng.below(rows);
+ std::hint::black_box(dt.select(pk));
+ }
+ rows
+ });
+ println!("ablation,dynamic,point_read,{rd:.0}");
+
+ let upd = median_ops_per_sec(reps, || {
+ let mut rng = Rng::new(7);
+ let n = rows / 10;
+ for _ in 0..n {
+ let pk = rng.below(rows);
+ dt.update_field(pk, "b", Value::U64(pk)).unwrap();
+ }
+ n
+ });
+ println!("ablation,dynamic,update_field,{upd:.0}");
+}
diff --git a/paper-bench/src/bin/baselines.rs b/paper-bench/src/bin/baselines.rs
new file mode 100644
index 0000000..6044d41
--- /dev/null
+++ b/paper-bench/src/bin/baselines.rs
@@ -0,0 +1,119 @@
+//! Hand-rolled Rust baselines (paper Table 2 rows 1-2): the structures a Rust
+//! engineer reaches for before adopting an engine. Bounds the price of the
+//! engine itself.
+//!
+//! CSV to stdout: bench,engine,op,ops_per_sec
+//! External DB baselines (sled/redb/LMDB/SQLite) are a separate follow-up
+//! crate so their heavy deps don't gate this one.
+
+use dashmap::DashMap;
+use parking_lot::RwLock;
+use std::collections::HashMap;
+use wt_paper_bench::util::*;
+
+#[derive(Clone, Debug)]
+struct Row {
+ #[allow(dead_code)]
+ id: u64,
+ a: u64,
+ b: u64,
+ e: u64,
+ c: f64,
+ d: String,
+}
+
+fn mk(id: u64) -> Row {
+ Row { id, a: id, b: id, e: id, c: id as f64, d: "payloadpayload".into() }
+}
+
+fn main() {
+ let rows = env_u64("ROWS", 1_000_000);
+ let reps = env_u64("REPS", 5) as usize;
+ println!("bench,engine,op,ops_per_sec");
+
+ // Vec — the absolute floor (pk == index)
+ let ins = median_ops_per_sec(reps, || {
+ let mut v: Vec = Vec::new();
+ for i in 0..rows {
+ v.push(mk(i));
+ }
+ std::hint::black_box(&v);
+ rows
+ });
+ println!("baseline,vec,insert,{ins:.0}");
+
+ let v: Vec = (0..rows).map(mk).collect();
+ let rd = median_ops_per_sec(reps, || {
+ let mut rng = Rng::new(42);
+ for _ in 0..rows {
+ let i = rng.below(rows) as usize;
+ std::hint::black_box(&v[i]);
+ }
+ rows
+ });
+ println!("baseline,vec,point_read,{rd:.0}");
+
+ // RwLock — simplest shared map
+ let m: RwLock> = RwLock::new(HashMap::new());
+ let ins = median_ops_per_sec(reps, || {
+ let mut g = m.write();
+ g.clear();
+ for i in 0..rows {
+ g.insert(i, mk(i));
+ }
+ rows
+ });
+ println!("baseline,rwlock_hashmap,insert,{ins:.0}");
+ let rd = median_ops_per_sec(reps, || {
+ let mut rng = Rng::new(42);
+ let g = m.read();
+ for _ in 0..rows {
+ std::hint::black_box(g.get(&rng.below(rows)));
+ }
+ rows
+ });
+ println!("baseline,rwlock_hashmap,point_read,{rd:.0}");
+ let upd = median_ops_per_sec(reps, || {
+ let mut rng = Rng::new(7);
+ let n = rows / 10;
+ for _ in 0..n {
+ let pk = rng.below(rows);
+ if let Some(r) = m.write().get_mut(&pk) {
+ r.b = pk;
+ }
+ }
+ n
+ });
+ println!("baseline,rwlock_hashmap,update_field,{upd:.0}");
+
+ // DashMap — sharded concurrent map
+ let dm: DashMap = DashMap::new();
+ let ins = median_ops_per_sec(reps, || {
+ dm.clear();
+ for i in 0..rows {
+ dm.insert(i, mk(i));
+ }
+ rows
+ });
+ println!("baseline,dashmap,insert,{ins:.0}");
+ let rd = median_ops_per_sec(reps, || {
+ let mut rng = Rng::new(42);
+ for _ in 0..rows {
+ std::hint::black_box(dm.get(&rng.below(rows)));
+ }
+ rows
+ });
+ println!("baseline,dashmap,point_read,{rd:.0}");
+ let upd = median_ops_per_sec(reps, || {
+ let mut rng = Rng::new(7);
+ let n = rows / 10;
+ for _ in 0..n {
+ let pk = rng.below(rows);
+ if let Some(mut r) = dm.get_mut(&pk) {
+ r.b = pk;
+ }
+ }
+ n
+ });
+ println!("baseline,dashmap,update_field,{upd:.0}");
+}
diff --git a/paper-bench/src/bin/contention.rs b/paper-bench/src/bin/contention.rs
new file mode 100644
index 0000000..dd083c6
--- /dev/null
+++ b/paper-bench/src/bin/contention.rs
@@ -0,0 +1,90 @@
+//! Lock-granularity contention matrix (paper §5, C2 claim).
+//!
+//! One hot row; N tasks update it continuously for DURATION_SECS.
+//! Modes:
+//! disjoint — half the tasks update column `b`, half update column `e`
+//! (disjoint write sets => field-granular locks don't collide)
+//! overlap — every task updates {b,e} (identical write sets => serialize)
+//! mutex — like disjoint, but behind one external tokio::Mutex
+//! (emulates a single-lock table)
+//! inplace — every task runs the in_place closure increment on `b`
+//!
+//! CSV to stdout: bench,mode,tasks,ops_per_sec
+
+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
+use std::sync::Arc;
+use std::time::Instant;
+use wt_paper_bench::util::*;
+use wt_paper_bench::*;
+
+async fn run(mode: &'static str, tasks: usize) -> f64 {
+ let table = Arc::new(BenchWorkTable::default());
+ let pk = table.insert(mk_row(&table, 1)).unwrap();
+ let pk_val: u64 = pk.into();
+
+ let stop = Arc::new(AtomicBool::new(false));
+ let total = Arc::new(AtomicU64::new(0));
+ let big_lock = Arc::new(tokio::sync::Mutex::new(()));
+
+ let mut handles = Vec::new();
+ for i in 0..tasks {
+ let table = table.clone();
+ let stop = stop.clone();
+ let total = total.clone();
+ let big_lock = big_lock.clone();
+ handles.push(tokio::spawn(async move {
+ let mut n = 0u64;
+ while !stop.load(Ordering::Relaxed) {
+ match mode {
+ "disjoint" => {
+ if i % 2 == 0 {
+ table.update_upd_b(UpdBQuery { b: n }, pk_val).await.unwrap();
+ } else {
+ table.update_upd_e(UpdEQuery { e: n }, pk_val).await.unwrap();
+ }
+ }
+ "overlap" => {
+ table.update_upd_be(UpdBEQuery { b: n, e: n }, pk_val).await.unwrap();
+ }
+ "mutex" => {
+ let _g = big_lock.lock().await;
+ if i % 2 == 0 {
+ table.update_upd_b(UpdBQuery { b: n }, pk_val).await.unwrap();
+ } else {
+ table.update_upd_e(UpdEQuery { e: n }, pk_val).await.unwrap();
+ }
+ }
+ "inplace" => {
+ table.update_inc_b_in_place(|b| *b += 1, pk_val).await.unwrap();
+ }
+ _ => unreachable!(),
+ }
+ n += 1;
+ }
+ total.fetch_add(n, Ordering::Relaxed);
+ }));
+ }
+
+ let dur = env_secs("DURATION_SECS", 5);
+ let t0 = Instant::now();
+ tokio::time::sleep(dur).await;
+ stop.store(true, Ordering::Relaxed);
+ for h in handles {
+ h.await.unwrap();
+ }
+ total.load(Ordering::Relaxed) as f64 / t0.elapsed().as_secs_f64()
+}
+
+#[tokio::main(flavor = "multi_thread")]
+async fn main() {
+ println!("bench,mode,tasks,ops_per_sec");
+ let task_counts = [1usize, 2, 4, 8, 16];
+ for mode in ["disjoint", "overlap", "mutex", "inplace"] {
+ for &t in &task_counts {
+ // warmup
+ let _ = run(mode, t).await;
+ let ops = run(mode, t).await;
+ println!("contention,{mode},{t},{ops:.0}");
+ }
+ }
+}
diff --git a/paper-bench/src/dynamic.rs b/paper-bench/src/dynamic.rs
new file mode 100644
index 0000000..f6e61a1
--- /dev/null
+++ b/paper-bench/src/dynamic.rs
@@ -0,0 +1,159 @@
+//! The "dynamic twin" for the specialization ablation (paper Table 1).
+//!
+//! Models what a dynamic-schema engine is forced to do on every operation:
+//! * rows are vectors of tagged values (boxed representation)
+//! * a runtime catalog maps column name -> position (hash lookup per access)
+//! * rows are encoded/decoded through a tag-dispatched serializer per access
+//! * row locking is a coarse per-row mutex from a dynamic lock table
+//!
+//! FAIRNESS NOTE (review before trusting numbers): this v1 twin does NOT share
+//! WorkTable's page allocator or B-tree; it uses a slot vector + BTreeMap.
+//! That is *favorable* to the twin for point ops (no page indirection), so a
+//! win for the specialized engine here is conservative. A v2 twin sharing the
+//! page/index machinery with only the row representation dynamized would
+//! isolate the typing cost even more precisely.
+
+use parking_lot::{Mutex, RwLock};
+use std::collections::{BTreeMap, HashMap};
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::Arc;
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum Value {
+ U64(u64),
+ F64(f64),
+ Str(String),
+}
+
+pub struct DynSchema {
+ pub cols: Vec<&'static str>,
+ pub catalog: HashMap<&'static str, usize>,
+}
+
+impl DynSchema {
+ pub fn bench() -> Self {
+ let cols = vec!["id", "a", "b", "e", "c", "d"];
+ let catalog = cols.iter().enumerate().map(|(i, c)| (*c, i)).collect();
+ DynSchema { cols, catalog }
+ }
+}
+
+fn encode(row: &[Value], out: &mut Vec) {
+ out.clear();
+ for v in row {
+ match v {
+ Value::U64(x) => {
+ out.push(0);
+ out.extend_from_slice(&x.to_le_bytes());
+ }
+ Value::F64(x) => {
+ out.push(1);
+ out.extend_from_slice(&x.to_le_bytes());
+ }
+ Value::Str(s) => {
+ out.push(2);
+ out.extend_from_slice(&(s.len() as u32).to_le_bytes());
+ out.extend_from_slice(s.as_bytes());
+ }
+ }
+ }
+}
+
+fn decode(mut b: &[u8]) -> Vec {
+ let mut row = Vec::with_capacity(6);
+ while !b.is_empty() {
+ match b[0] {
+ 0 => {
+ row.push(Value::U64(u64::from_le_bytes(b[1..9].try_into().unwrap())));
+ b = &b[9..];
+ }
+ 1 => {
+ row.push(Value::F64(f64::from_le_bytes(b[1..9].try_into().unwrap())));
+ b = &b[9..];
+ }
+ 2 => {
+ let n = u32::from_le_bytes(b[1..5].try_into().unwrap()) as usize;
+ row.push(Value::Str(String::from_utf8(b[5..5 + n].to_vec()).unwrap()));
+ b = &b[5 + n..];
+ }
+ _ => unreachable!(),
+ }
+ }
+ row
+}
+
+pub struct DynTable {
+ pub schema: DynSchema,
+ slots: RwLock>>,
+ pk_index: RwLock>,
+ locks: RwLock>>>,
+ next_pk: AtomicU64,
+}
+
+impl DynTable {
+ pub fn new() -> Self {
+ DynTable {
+ schema: DynSchema::bench(),
+ slots: RwLock::new(Vec::new()),
+ pk_index: RwLock::new(BTreeMap::new()),
+ locks: RwLock::new(HashMap::new()),
+ next_pk: AtomicU64::new(0),
+ }
+ }
+
+ pub fn get_next_pk(&self) -> u64 {
+ self.next_pk.fetch_add(1, Ordering::AcqRel)
+ }
+
+ pub fn insert(&self, row: Vec) -> u64 {
+ let pk = match &row[0] {
+ Value::U64(x) => *x,
+ _ => panic!("pk must be u64"),
+ };
+ let mut buf = Vec::with_capacity(64);
+ encode(&row, &mut buf);
+ let mut slots = self.slots.write();
+ let idx = slots.len();
+ slots.push(buf);
+ drop(slots);
+ self.pk_index.write().insert(pk, idx);
+ pk
+ }
+
+ pub fn select(&self, pk: u64) -> Option> {
+ let idx = *self.pk_index.read().get(&pk)?;
+ let slots = self.slots.read();
+ Some(decode(&slots[idx]))
+ }
+
+ /// Field update through the catalog — the dynamic path a specialized
+ /// `update_upd_b` avoids: lock table, hash lookup, decode, dispatch,
+ /// re-encode, write back.
+ pub fn update_field(&self, pk: u64, col: &str, v: Value) -> Option<()> {
+ let lock = {
+ let mut locks = self.locks.write();
+ locks.entry(pk).or_insert_with(|| Arc::new(Mutex::new(()))).clone()
+ };
+ let _g = lock.lock();
+ let idx = *self.pk_index.read().get(&pk)?;
+ let pos = *self.schema.catalog.get(col)?;
+ let mut slots = self.slots.write();
+ let mut row = decode(&slots[idx]);
+ row[pos] = v;
+ let mut buf = Vec::with_capacity(64);
+ encode(&row, &mut buf);
+ slots[idx] = buf;
+ Some(())
+ }
+}
+
+pub fn mk_dyn_row(pk: u64, v: u64) -> Vec {
+ vec![
+ Value::U64(pk),
+ Value::U64(v),
+ Value::U64(v),
+ Value::U64(v),
+ Value::F64(v as f64),
+ Value::Str("payloadpayload".to_string()),
+ ]
+}
diff --git a/paper-bench/src/lib.rs b/paper-bench/src/lib.rs
new file mode 100644
index 0000000..5b19bd8
--- /dev/null
+++ b/paper-bench/src/lib.rs
@@ -0,0 +1,46 @@
+pub mod dynamic;
+pub mod util;
+
+use worktable::prelude::*;
+use worktable::worktable;
+
+// The paper's benchmark table. `a` is indexed (non-unique) so we can measure
+// indexed vs non-indexed update cost separately. `b`/`e` are the disjoint
+// contention pair (both non-indexed). `d` keeps the table "unsized" (String),
+// matching realistic schemas.
+worktable!(
+ name: Bench,
+ columns: {
+ id: u64 primary_key autoincrement,
+ a: u64,
+ b: u64,
+ e: u64,
+ c: f64,
+ d: String,
+ },
+ indexes: {
+ a_idx: a,
+ },
+ queries: {
+ update: {
+ UpdA(a) by id,
+ UpdB(b) by id,
+ UpdE(e) by id,
+ UpdBE(b, e) by id,
+ },
+ in_place: {
+ IncB(b) by id,
+ }
+ }
+);
+
+pub fn mk_row(table: &BenchWorkTable, v: u64) -> BenchRow {
+ BenchRow {
+ id: table.get_next_pk().into(),
+ a: v,
+ b: v,
+ e: v,
+ c: v as f64,
+ d: "payloadpayload".to_string(), // fixed-length: updates stay in place
+ }
+}
diff --git a/paper-bench/src/util.rs b/paper-bench/src/util.rs
new file mode 100644
index 0000000..5e46f28
--- /dev/null
+++ b/paper-bench/src/util.rs
@@ -0,0 +1,43 @@
+use std::time::{Duration, Instant};
+
+/// Tiny xorshift RNG — no external dep, deterministic across runs.
+pub struct Rng(u64);
+impl Rng {
+ pub fn new(seed: u64) -> Self {
+ Rng(seed | 1)
+ }
+ #[inline]
+ pub fn next(&mut self) -> u64 {
+ let mut x = self.0;
+ x ^= x << 13;
+ x ^= x >> 7;
+ x ^= x << 17;
+ self.0 = x;
+ x
+ }
+ #[inline]
+ pub fn below(&mut self, n: u64) -> u64 {
+ self.next() % n
+ }
+}
+
+pub fn env_u64(key: &str, default: u64) -> u64 {
+ std::env::var(key).ok().and_then(|v| v.parse().ok()).unwrap_or(default)
+}
+
+pub fn env_secs(key: &str, default: u64) -> Duration {
+ Duration::from_secs(env_u64(key, default))
+}
+
+/// Run `f` repeatedly for `reps`, return median ops/sec given per-rep op count.
+pub fn median_ops_per_sec u64>(reps: usize, mut f: F) -> f64 {
+ let mut rates: Vec = (0..reps)
+ .map(|_| {
+ let t = Instant::now();
+ let ops = f();
+ ops as f64 / t.elapsed().as_secs_f64()
+ })
+ .collect();
+ rates.sort_by(|a, b| a.partial_cmp(b).unwrap());
+ rates[rates.len() / 2]
+}