feat: conformance tests pass for first time

Change implementation to exponentially increase search space at each
level.
This commit is contained in:
nobody 2025-12-12 21:05:29 -08:00
commit 546d6deb69
Signed by: GrocerPublishAgent
GPG key ID: D460CD54A9E3AB86
13 changed files with 1852 additions and 102 deletions

4
.gitignore vendored
View file

@ -1,5 +1,5 @@
# TypeScript # TypeScript
typescript/node_modules/ node_modules/
typescript/dist/ typescript/dist/
typescript/*.tsbuildinfo typescript/*.tsbuildinfo
typescript/.nyc_output/ typescript/.nyc_output/
@ -44,3 +44,5 @@ pids
.env.test.local .env.test.local
.env.production.local .env.production.local
.env.local .env.local
conformance-tests/genfiles/

View file

@ -0,0 +1,28 @@
# Conformance Tests
I'm using the Rust implementation as the source of truth. It was pretty easy to write unit tests and measure performance in Rust. I'm most familiar with testing tools in Rust so I did all the validation work there. Now I just want to check that all the other language implementations behave exactly the same as the Rust version, which I poured all this energy into validating for correctness.
## Running the tests
| Language | Directory | Command |
|----------|-----------|---------|
| TypeScript | `runners/typescript` | `npm run test` |
## Generating test data
To generate the test data, run:
```
cd generator
cargo run
```
This produces `.scenario.json` files in `genfiles/`. Each file contains a sequence of random numbers and the expected outputs from the Rust implementation.
The idea is that we replay the same sequence of random numbers for each implementation. All of the different language libraries allow you to inject the source of randomness, so the tests just inject a mock random generator that reads from the recorded sequence. In theory, every implementation makes the exact same calls to random in the same order, and they all produce identical output.
## Scenario format
You can test the entire public API by assuming you have a sorted list of sort keys. A sort key is just an LSEQ identifier. Each scenario has an `initialState` array of sort keys in sorted order. Then there's a list of operations: pick an array index to insert before or after, which tells you which two boundary keys to use for the `alloc` function from the paper. You call alloc, insert the result into the list, and repeat.
This setup can test everything you'd want to test, even if it's a bit indirect for very basic cases. You could still write basic unit tests in each language for the simple stuff.

204
conformance-tests/generator/Cargo.lock generated Normal file
View file

@ -0,0 +1,204 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "conformance-test-generator"
version = "0.1.0"
dependencies = [
"peoplesgrocers-lseq",
"rand",
"serde",
"serde_json",
]
[[package]]
name = "getrandom"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "itoa"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "libc"
version = "0.2.178"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091"
[[package]]
name = "memchr"
version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]]
name = "peoplesgrocers-lseq"
version = "1.0.0"
dependencies = [
"rand",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "ryu"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.145"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
"serde_core",
]
[[package]]
name = "syn"
version = "2.0.111"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "zerocopy"
version = "0.8.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a"
dependencies = [
"proc-macro2",
"quote",
"syn",
]

View file

@ -0,0 +1,10 @@
[package]
name = "conformance-test-generator"
version = "0.1.0"
edition = "2024"
[dependencies]
peoplesgrocers-lseq = { path = "../../rust" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
rand = "0.8"

View file

@ -0,0 +1,254 @@
mod model;
use model::{Insert, Scenario};
use peoplesgrocers_lseq::{LseqRng, SortKey, LSEQ};
use rand::{rngs::StdRng, Rng, SeedableRng};
use std::fs;
/// A recording RNG wrapper that records random values as floats
/// compatible with TypeScript's Math.random() consumption pattern.
struct RecordingRng {
inner: StdRng,
recorded: Vec<f64>,
}
impl RecordingRng {
fn new(seed: u64) -> Self {
RecordingRng {
inner: StdRng::seed_from_u64(seed),
recorded: Vec::new(),
}
}
fn take_recorded(&mut self) -> Vec<f64> {
std::mem::take(&mut self.recorded)
}
}
impl LseqRng for RecordingRng {
fn gen_bool(&mut self, p: f64) -> bool {
// Use fully qualified syntax to call rand::Rng method
let result = Rng::gen_bool(&mut self.inner, p);
// Record a float that TypeScript's `f < p` would produce the same result
// If result is true, we need f < p, so use p/2
// If result is false, we need f >= p, so use (1 + p) / 2
let recorded_value = if result { p / 2.0 } else { (1.0 + p) / 2.0 };
self.recorded.push(recorded_value);
result
}
fn gen_range(&mut self, range: std::ops::Range<u64>) -> u64 {
// Use fully qualified syntax to call rand::Rng method
let result: u64 = Rng::gen_range(&mut self.inner, range.clone());
// Record a float that TypeScript's `Math.floor(f * range_size)` would produce the same result
// For result r in [0, range_size), we need floor(f * range_size) = r
// So f should be in [r/range_size, (r+1)/range_size)
// Use midpoint: (r + 0.5) / range_size
let range_size = (range.end - range.start) as f64;
let relative_result = (result - range.start) as f64;
let recorded_value = (relative_result + 0.5) / range_size;
self.recorded.push(recorded_value);
result
}
}
fn generate_scenario(
name: &str,
description: &str,
seed: u64,
init: Vec<&str>,
ops: impl FnOnce(&mut LSEQ<RecordingRng>, &mut Vec<String>) -> Vec<Insert>,
) -> Scenario {
let mut lseq = LSEQ::new(RecordingRng::new(seed));
let mut state: Vec<String> = init.iter().map(|s| s.to_string()).collect();
let operations = ops(&mut lseq, &mut state);
Scenario {
name: name.to_string(),
description: description.to_string(),
seed,
init: init.iter().map(|s| s.to_string()).collect(),
rng: lseq.take_rng().take_recorded(),
operations,
}
}
fn main() {
let scenarios = vec![
// 01 - Sequential append
generate_scenario(
"sequential-append",
"20 inserts, each after the last",
42,
vec![],
|lseq, state| {
let mut ops = Vec::new();
for _ in 0..20 {
let before_key = state.last().map(|s| s.parse::<SortKey>().unwrap());
let result = lseq.alloc(before_key.as_ref(), None);
let result_str = result.to_string();
ops.push(Insert::After {
index: -1, // after last element (or beginning if empty)
outcome: result_str.clone(),
});
state.push(result_str);
}
ops
},
),
// 02 - Sequential prepend
generate_scenario(
"sequential-prepend",
"20 inserts, each before the first",
43,
vec![],
|lseq, state| {
let mut ops = Vec::new();
for _ in 0..20 {
let after_key = state.first().map(|s| s.parse::<SortKey>().unwrap());
let result = lseq.alloc(None, after_key.as_ref());
let result_str = result.to_string();
ops.push(Insert::Before {
index: 0, // before first element (prepend)
outcome: result_str.clone(),
});
state.insert(0, result_str);
}
ops
},
),
// 03 - Random insert 100 items
generate_scenario(
"random-insert-100-items",
"100 inserts at random positions",
44,
vec![],
|lseq, state| {
let mut ops = Vec::new();
let mut position_rng = StdRng::seed_from_u64(100); // Separate RNG for positions
for _ in 0..100 {
// Pick a random insertion point (0..=state.len())
let idx = if state.is_empty() {
0
} else {
Rng::gen_range(&mut position_rng, 0..=state.len())
};
// Derive beforeKey and afterKey from the insertion index
let before_key = if idx > 0 {
Some(state[idx - 1].parse::<SortKey>().unwrap())
} else {
None
};
let after_key = if idx < state.len() {
Some(state[idx].parse::<SortKey>().unwrap())
} else {
None
};
let result = lseq.alloc(before_key.as_ref(), after_key.as_ref());
let result_str = result.to_string();
ops.push(Insert::Before {
index: idx as i32,
outcome: result_str.clone(),
});
state.insert(idx, result_str);
}
ops
},
),
// 04 - Dense packing
generate_scenario(
"dense-packing",
"20 inserts between adjacent keys '0' and '1'",
45,
vec!["0", "1"],
|lseq, state| {
let mut ops = Vec::new();
// Always insert between first and second element
for _ in 0..20 {
let before_key = state[0].parse::<SortKey>().unwrap();
let after_key = state[1].parse::<SortKey>().unwrap();
let result = lseq.alloc(Some(&before_key), Some(&after_key));
let result_str = result.to_string();
ops.push(Insert::Before {
index: 1, // between state[0] and state[1]
outcome: result_str.clone(),
});
state.insert(1, result_str);
}
ops
},
),
// 05 - Deep nesting
generate_scenario(
"deep-nesting",
"Force 5+ level deep keys by inserting between adjacent keys",
46,
vec!["M", "N"],
|lseq, state| {
let mut ops = Vec::new();
// Keep inserting between first two to force depth
for _ in 0..30 {
let before_key = state[0].parse::<SortKey>().unwrap();
let after_key = state[1].parse::<SortKey>().unwrap();
let result = lseq.alloc(Some(&before_key), Some(&after_key));
let result_str = result.to_string();
ops.push(Insert::Before {
index: 1, // between state[0] and state[1]
outcome: result_str.clone(),
});
state.insert(1, result_str);
}
ops
},
),
// 06 - Edge min interval
generate_scenario(
"edge-min-interval",
"Insert between adjacent keys (A, B) which have interval=1",
47,
vec!["A", "B"],
|lseq, state| {
let mut ops = Vec::new();
for _ in 0..10 {
let before_key = state[0].parse::<SortKey>().unwrap();
let after_key = state[1].parse::<SortKey>().unwrap();
let result = lseq.alloc(Some(&before_key), Some(&after_key));
let result_str = result.to_string();
ops.push(Insert::Before {
index: 1, // between state[0] and state[1]
outcome: result_str.clone(),
});
state.insert(1, result_str);
}
ops
},
),
];
// Write each scenario to a file
let output_dir = "../genfiles";
fs::create_dir_all(output_dir).expect("Failed to create genfiles directory");
for (i, scenario) in scenarios.iter().enumerate() {
let filename = format!("{}/{:02}-{}.scenario.json", output_dir, i + 1, scenario.name);
let json = serde_json::to_string_pretty(scenario).expect("Failed to serialize scenario");
fs::write(&filename, json).expect("Failed to write scenario file");
println!("Generated: {}", filename);
}
}

View file

@ -0,0 +1,46 @@
use serde::ser::{SerializeMap, Serializer};
use serde::Serialize;
#[derive(Serialize)]
pub struct Scenario {
pub name: String,
pub description: String,
pub seed: u64,
pub init: Vec<String>,
pub rng: Vec<f64>,
pub operations: Vec<Insert>,
}
/// Specifies an insertion point in the sequence.
///
/// - `Insert::Before { index: 3, .. }` → insert before state[3]
/// - `Insert::Before { index: 0, .. }` → insert at start
/// - `Insert::After { index: 2, .. }` → insert after state[2]
/// - `Insert::After { index: -1, .. }` → insert at end (Python-style negative index)
///
/// Serializes to `{ "before": n, "expected": "..." }` or `{ "after": n, "expected": "..." }`.
#[derive(Debug)]
pub enum Insert {
Before { index: i32, outcome: String },
After { index: i32, outcome: String },
}
impl Serialize for Insert {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut map = serializer.serialize_map(Some(2))?;
match self {
Insert::Before { index, outcome } => {
map.serialize_entry("before", index)?;
map.serialize_entry("expected", outcome)?;
}
Insert::After { index, outcome } => {
map.serialize_entry("after", index)?;
map.serialize_entry("expected", outcome)?;
}
}
map.end()
}
}

View file

View file

@ -0,0 +1,674 @@
{
"name": "lseq-conformance-runner",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lseq-conformance-runner",
"version": "1.0.0",
"dependencies": {
"@peoplesgrocers/lseq": "file:../../../typescript",
"uvu": "^0.5.6"
},
"devDependencies": {
"@types/node": "^20.0.0",
"tsx": "^4.7.0",
"typescript": "^5.0.0"
}
},
"../../../typescript": {
"name": "@peoplesgrocers/lseq",
"version": "1.0.0",
"license": "SEE LICENSE IN LICENSE.txt",
"devDependencies": {
"tsx": "^4.7.0",
"typescript": "^5.0.0",
"uvu": "^0.5.6"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.1.tgz",
"integrity": "sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.1.tgz",
"integrity": "sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.1.tgz",
"integrity": "sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.1.tgz",
"integrity": "sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.1.tgz",
"integrity": "sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.1.tgz",
"integrity": "sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.1.tgz",
"integrity": "sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.1.tgz",
"integrity": "sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.1.tgz",
"integrity": "sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.1.tgz",
"integrity": "sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.1.tgz",
"integrity": "sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.1.tgz",
"integrity": "sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.1.tgz",
"integrity": "sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.1.tgz",
"integrity": "sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.1.tgz",
"integrity": "sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.1.tgz",
"integrity": "sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.1.tgz",
"integrity": "sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.1.tgz",
"integrity": "sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.1.tgz",
"integrity": "sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.1.tgz",
"integrity": "sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.1.tgz",
"integrity": "sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.1.tgz",
"integrity": "sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.1.tgz",
"integrity": "sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.1.tgz",
"integrity": "sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.1.tgz",
"integrity": "sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.1.tgz",
"integrity": "sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@peoplesgrocers/lseq": {
"resolved": "../../../typescript",
"link": true
},
"node_modules/@types/node": {
"version": "20.19.26",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.26.tgz",
"integrity": "sha512-0l6cjgF0XnihUpndDhk+nyD3exio3iKaYROSgvh/qSevPXax3L8p5DBRFjbvalnwatGgHEQn2R88y2fA3g4irg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/diff": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz",
"integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/esbuild": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.1.tgz",
"integrity": "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.1",
"@esbuild/android-arm": "0.27.1",
"@esbuild/android-arm64": "0.27.1",
"@esbuild/android-x64": "0.27.1",
"@esbuild/darwin-arm64": "0.27.1",
"@esbuild/darwin-x64": "0.27.1",
"@esbuild/freebsd-arm64": "0.27.1",
"@esbuild/freebsd-x64": "0.27.1",
"@esbuild/linux-arm": "0.27.1",
"@esbuild/linux-arm64": "0.27.1",
"@esbuild/linux-ia32": "0.27.1",
"@esbuild/linux-loong64": "0.27.1",
"@esbuild/linux-mips64el": "0.27.1",
"@esbuild/linux-ppc64": "0.27.1",
"@esbuild/linux-riscv64": "0.27.1",
"@esbuild/linux-s390x": "0.27.1",
"@esbuild/linux-x64": "0.27.1",
"@esbuild/netbsd-arm64": "0.27.1",
"@esbuild/netbsd-x64": "0.27.1",
"@esbuild/openbsd-arm64": "0.27.1",
"@esbuild/openbsd-x64": "0.27.1",
"@esbuild/openharmony-arm64": "0.27.1",
"@esbuild/sunos-x64": "0.27.1",
"@esbuild/win32-arm64": "0.27.1",
"@esbuild/win32-ia32": "0.27.1",
"@esbuild/win32-x64": "0.27.1"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
"integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/kleur": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
"integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/sade": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
"integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
"license": "MIT",
"dependencies": {
"mri": "^1.1.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/uvu": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/uvu/-/uvu-0.5.6.tgz",
"integrity": "sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.0",
"diff": "^5.0.0",
"kleur": "^4.0.3",
"sade": "^1.7.3"
},
"bin": {
"uvu": "bin.js"
},
"engines": {
"node": ">=8"
}
}
}
}

View file

@ -0,0 +1,18 @@
{
"name": "lseq-conformance-runner",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"test": "tsx src/runner.ts"
},
"dependencies": {
"@peoplesgrocers/lseq": "file:../../../typescript",
"uvu": "^0.5.6"
},
"devDependencies": {
"@types/node": "^20.0.0",
"tsx": "^4.7.0",
"typescript": "^5.0.0"
}
}

View file

@ -0,0 +1,89 @@
import { LSEQ } from "@peoplesgrocers/lseq";
import { test } from "uvu";
import * as assert from "uvu/assert";
import * as fs from "fs";
import * as path from "path";
interface Operation {
before?: number;
after?: number;
expected: string;
}
interface Scenario {
name: string;
description: string;
seed: number;
init: string[];
rng: number[];
operations: Operation[];
}
function createMockRandom(values: number[]): () => number {
let index = 0;
return () => {
if (index >= values.length) {
throw new Error(`Ran out of random values at index ${index}`);
}
return values[index++];
};
}
const testDataDir = path.join(import.meta.dirname!, "../../../genfiles");
const files = fs
.readdirSync(testDataDir)
.filter((f) => f.endsWith(".scenario.json"))
.sort();
for (const file of files) {
const filePath = path.join(testDataDir, file);
const scenario: Scenario = JSON.parse(fs.readFileSync(filePath, "utf-8"));
test(`${scenario.name}`, () => {
const mockRandom = createMockRandom(scenario.rng);
const lseq = new LSEQ(mockRandom);
const state: string[] = [...scenario.init];
for (let i = 0; i < scenario.operations.length; i++) {
const op = scenario.operations[i];
// Derive beforeKey and afterKey from the insertion point
// - `before: X` means insert before index X → beforeKey=state[X-1], afterKey=state[X]
// - `after: X` means insert after index X → beforeKey=state[X], afterKey=state[X+1]
let beforeKey: string | null = null;
let afterKey: string | null = null;
let insertIdx: number;
if (op.before !== undefined) {
// Insert before index X
const idx = op.before < 0 ? state.length + op.before : op.before;
beforeKey = idx > 0 ? state[idx - 1] : null;
afterKey = idx < state.length ? state[idx] : null;
insertIdx = idx;
} else if (op.after !== undefined) {
// Insert after index X
const idx = op.after < 0 ? state.length + op.after : op.after;
beforeKey = idx >= 0 && idx < state.length ? state[idx] : null;
afterKey = idx + 1 < state.length ? state[idx + 1] : null;
insertIdx = idx + 1;
} else {
// Neither specified - insert at end
beforeKey = state.length > 0 ? state[state.length - 1] : null;
afterKey = null;
insertIdx = state.length;
}
const result = lseq.alloc(beforeKey, afterKey);
assert.is(
result,
op.expected,
`op ${i}: alloc(${beforeKey}, ${afterKey})`
);
state.splice(insertIdx, 0, result);
}
});
}
test.run();

View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
}

View file

@ -10,11 +10,39 @@ use std::str::FromStr;
const ALPHABET: &[u8] = b"-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz"; const ALPHABET: &[u8] = b"-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz";
/// Minimal RNG trait for LSEQ - only the methods we actually use.
/// This allows custom implementations (e.g., recording wrappers) without
/// implementing the full Rng trait.
pub trait LseqRng {
fn gen_bool(&mut self, p: f64) -> bool;
fn gen_range(&mut self, range: std::ops::Range<u64>) -> u64;
}
/// Blanket implementation for anything that implements rand::Rng
impl<R: Rng> LseqRng for R {
fn gen_bool(&mut self, p: f64) -> bool {
Rng::gen_bool(self, p)
}
fn gen_range(&mut self, range: std::ops::Range<u64>) -> u64 {
Rng::gen_range(self, range)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
#[cfg_attr(feature = "serde", derive(Serialize))] #[cfg_attr(feature = "serde", derive(Serialize))]
#[cfg_attr(feature = "serde", serde(into = "String"))] #[cfg_attr(feature = "serde", serde(into = "String"))]
pub struct SortKey { pub struct SortKey {
numbers: Vec<u8>, /// Each element is a value at one level of the LSEQ tree.
/// Level n (0-indexed) holds values 0 to 64^(n+1) - 1, capped at 64^8.
///
/// String encoding length grows as triangular numbers only up to depth 8:
/// Depths 1-8: 1, 3, 6, 10, 15, 21, 28, 36 chars (triangular)
/// Depths 9+: 44, 52, 60, 68, ... chars (+8 per level, linear)
///
/// We cap at 64^8 = 2^48 per level for JavaScript float compatibility (2^53 max).
/// But we can still keep going deeper: even at 8 chars per level, the total
/// address space is (2^48)^depth which remains astronomically large.
numbers: Vec<u64>,
} }
#[cfg(feature = "serde")] #[cfg(feature = "serde")]
@ -44,14 +72,34 @@ impl<'de> Deserialize<'de> for SortKey {
} }
} }
/// Maximum exponent for level values, capped for JavaScript compatibility.
/// JavaScript numbers are IEEE 754 floats with 53 bits of precision.
/// 64^8 = 2^48, which is safely within 2^53.
const MAX_LEVEL_EXPONENT: u32 = 8;
impl SortKey { impl SortKey {
pub fn from_numbers(numbers: Vec<u8>) -> Self { pub fn from_numbers(numbers: Vec<u64>) -> Self {
SortKey { numbers } SortKey { numbers }
} }
/// Returns the maximum value for a given level (0-indexed).
/// Level 0: 64^1 - 1 = 63
/// Level 1: 64^2 - 1 = 4095
/// ...
/// Level 7+: 64^8 - 1 (capped for JS compatibility)
fn max_value_for_level(level: usize) -> u64 {
let exp = (level as u32 + 1).min(MAX_LEVEL_EXPONENT);
64u64.pow(exp) - 1
}
/// Returns the number of characters needed to encode a value at this level.
fn chars_for_level(level: usize) -> usize {
(level + 1).min(MAX_LEVEL_EXPONENT as usize)
}
} }
impl From<SortKey> for Vec<u8> { impl From<SortKey> for Vec<u64> {
fn from(key: SortKey) -> Vec<u8> { fn from(key: SortKey) -> Vec<u64> {
key.numbers key.numbers
} }
} }
@ -62,8 +110,8 @@ impl From<SortKey> for String {
} }
} }
impl AsRef<[u8]> for SortKey { impl AsRef<[u64]> for SortKey {
fn as_ref(&self) -> &[u8] { fn as_ref(&self) -> &[u64] {
&self.numbers &self.numbers
} }
} }
@ -76,8 +124,21 @@ impl From<String> for SortKey {
impl fmt::Display for SortKey { impl fmt::Display for SortKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for &n in &self.numbers { for (level, &value) in self.numbers.iter().enumerate() {
write!(f, "{}", ALPHABET[n as usize] as char)?; let num_chars = Self::chars_for_level(level);
let mut chars = Vec::with_capacity(num_chars);
let mut v = value;
// Extract digits from least significant to most significant
for _ in 0..num_chars {
chars.push(ALPHABET[(v % 64) as usize] as char);
v /= 64;
}
// Write in reverse (most significant first)
for c in chars.into_iter().rev() {
write!(f, "{}", c)?;
}
} }
Ok(()) Ok(())
} }
@ -85,52 +146,106 @@ impl fmt::Display for SortKey {
#[allow(dead_code)] #[allow(dead_code)]
#[derive(Debug)] #[derive(Debug)]
pub struct LSEQ<R: Rng> { pub struct LSEQ<R: LseqRng> {
strategies: Vec<bool>, strategies: Vec<bool>,
rng: R, rng: R,
} }
#[allow(dead_code)] #[allow(dead_code)]
impl<R: Rng> LSEQ<R> { impl<R: LseqRng> LSEQ<R> {
pub fn new(mut rng: R) -> Self { pub fn new(mut rng: R) -> Self {
let strategies = vec![rng.gen_bool(0.5)]; let strategies = vec![rng.gen_bool(0.5)];
LSEQ { strategies, rng } LSEQ { strategies, rng }
} }
pub fn alloc(&mut self, before: Option<&SortKey>, after: Option<&SortKey>) -> SortKey { /// Consume the LSEQ and return the inner RNG
// Convert to numeric arrays, using boundary values for null pub fn take_rng(self) -> R {
let p = before.map_or(vec![0], |s| s.numbers.clone()); self.rng
let q = after.map_or(vec![63], |s| s.numbers.clone()); }
/// Allocate a new sort key between `before` and `after`.
///
/// # The invariant
///
/// This function guarantees you can always allocate a key that sorts before
/// any previously allocated key. This is essential for CRDT list insertion.
///
/// # The encoding
///
/// Keys use a 64-character alphabet where `'-'` is 0 and `'z'` is 63, chosen
/// so that `strcmp` matches numeric comparison. Keys are paths through an
/// LSEQ tree where each level has exponentially more space:
///
/// ```text
/// Level 1: 64¹ values → 1 char "-", "0", ..., "z"
/// Level 2: 64² values → 2 chars "--", "-0", ..., "zz"
/// Level 3: 64³ values → 3 chars "---", "--0", ..., "zzz"
/// ```
///
/// A path is encoded by concatenating each level's representation:
///
/// ```text
/// [0] = ["-"] = "-" (1 char)
/// [0, 1] = ["-", "-0"] = "--0" (1 + 2 = 3 chars)
/// [0, 0] = ["-", "--"] = "---" (1 + 2 = 3 chars)
/// [0, 0, 1] = ["-", "--", "--0"] = "-----0" (1 + 2 + 3 = 6 chars)
/// ```
///
/// # Why we go deeper than the LSEQ paper
///
/// With `strcmp`, `"-"` == `"---"` == `"------"` in a crucial sense: nothing
/// can sort before any of them. All-zeros at any depth is "negative infinity".
///
/// The LSEQ paper says: to insert before `[0, 1]` (= `"--0"`), use `[0, 0]`.
/// But `[0, 0]` = `"---"`, and nothing can ever sort before that!
///
/// This implementation goes one level deeper to preserve the invariant:
///
/// ```text
/// Insert before "--0" (i.e., [0, 1])?
/// Paper says: use [0, 0] = "---" → dead end
/// We say: use [0, 0, X] = "---" + X → can still prepend [0, 0, Y] where Y < X
/// ```
///
/// The cost is longer keys, but we guarantee indefinite prepending.
pub fn alloc(&mut self, before: Option<&SortKey>, after: Option<&SortKey>) -> SortKey {
let p = before.map_or(vec![], |s| s.numbers.clone());
let q = after.map_or(vec![], |s| s.numbers.clone());
// Walk through digits looking for space
let mut depth = 0; let mut depth = 0;
let mut result = Vec::new(); let mut result: Vec<u64> = Vec::new();
loop { loop {
let p_val = if depth < p.len() { p[depth] } else { 0 }; let p_val = p.get(depth).copied().unwrap_or(0);
let q_val = if depth < q.len() { q[depth] } else { 63 }; let q_upper = q.get(depth).copied();
let level_max = SortKey::max_value_for_level(depth);
let interval = q_val as i32 - p_val as i32; // Minimum allocatable (inclusive): one above the lower bound.
// This naturally reserves value 0 when p_val=0, ensuring we never
// allocate an all-zeros key. If we allocate [0, 1] and later need
// to prepend before it, we simply go deeper to get [0, 0, X].
let min_alloc = p_val + 1;
// If we have space between values at this depth // Maximum allocatable (inclusive):
if interval > 1 { // - With upper bound: one below it
// Pick a value in the available range // - Without upper bound (after=None): full range for this level
let range = interval - 1; let max_alloc = q_upper.map_or(level_max, |q| q.saturating_sub(1));
let add_val = 1 + self.rng.gen_range(0..range) as u8;
if min_alloc <= max_alloc {
let range = max_alloc - min_alloc + 1;
let offset = self.rng.gen_range(0..range);
let new_value = if self.strategies[depth] { let new_value = if self.strategies[depth] {
p_val + add_val min_alloc + offset
} else { } else {
q_val - add_val max_alloc - offset
}; };
// Take the prefix from p up to depth and append our new value
result.push(new_value); result.push(new_value);
return SortKey::from_numbers(result); return SortKey::from_numbers(result);
} }
// Descend to next level
result.push(p_val); result.push(p_val);
// If values are the same or adjacent at this depth,
// continue to next depth
depth += 1; depth += 1;
if depth >= self.strategies.len() { if depth >= self.strategies.len() {
self.strategies.push(self.rng.gen_bool(0.5)); self.strategies.push(self.rng.gen_bool(0.5));
@ -165,21 +280,21 @@ pub struct EvenSpacingIterator {
} }
impl EvenSpacingIterator { impl EvenSpacingIterator {
// Static table of (64^k - 2) values for k from 1 to 9 // Static table of (64^k - 1) values for k from 1 to 8
// We subtract 2 from each space size because we need to reserve two boundary positions: // We subtract 1 because we reserve only the lower boundary (position 0, all "-"s).
// 1. Position 0 (represented by "-") is reserved as the lower boundary // Position 0 cannot be used because nothing can be lexicographically less than it.
// 2. Position 63 (represented by "z") is reserved as the upper boundary // The upper boundary (all "z"s) IS usable because we can always insert after it
// This ensures we can always insert elements at the very beginning or end of the sequence // by extending: "zzz" < "zzza" lexicographically (prefix comparison).
const USABLE_SPACE: [usize; 9] = [ // Capped at 64^8 = 2^48 for JavaScript number compatibility (max safe: 2^53).
64 - 2, // 64^1 - 2 const USABLE_SPACE: [usize; 8] = [
4096 - 2, // 64^2 - 2 64 - 1, // 64^1 - 1
262144 - 2, // 64^3 - 2 4096 - 1, // 64^2 - 1
16777216 - 2, // 64^4 - 2 262144 - 1, // 64^3 - 1
1073741824 - 2, // 64^5 - 2 16777216 - 1, // 64^4 - 1
68719476736 - 2, // 64^6 - 2 1073741824 - 1, // 64^5 - 1
4398046511104 - 2, // 64^7 - 2 68719476736 - 1, // 64^6 - 1
281474976710656 - 2, // 64^8 - 2 4398046511104 - 1, // 64^7 - 1
18014398509481984 - 2, // 64^9 - 2 281474976710656 - 1, // 64^8 - 1
]; ];
pub fn new(total_items: usize) -> Result<(u64, Self), SpacingError> { pub fn new(total_items: usize) -> Result<(u64, Self), SpacingError> {
@ -222,25 +337,25 @@ impl EvenSpacingIterator {
)) ))
} }
// Helper method to convert a position to a sort key /// Convert a position within a level-k space to a SortKey.
///
/// Creates a k-level key where levels 0 through k-2 are 0, and level k-1
/// contains the position value.
///
/// Example: position_to_key(2, 1) = [0, 1] which displays as "--0"
pub fn position_to_key(k: u64, position: u64) -> SortKey { pub fn position_to_key(k: u64, position: u64) -> SortKey {
let mut result = Vec::with_capacity(k as usize); let mut result = Vec::with_capacity(k as usize);
let mut pos = position;
const BASE: u64 = 64;
// Fill in digits from least significant to most significant // Levels 0 through k-2 are 0
for _ in 0..k { for _ in 0..k.saturating_sub(1) {
// SAFETY: digit is guaranteed to be in bounds because: result.push(0);
// 1. digit = pos % base where base is 64 }
// 2. ALPHABET has exactly 64 elements
// Therefore digit as u64 will always be 0-63 // Level k-1 contains the position
let digit = (pos % BASE) as u8; if k > 0 {
pos /= BASE; result.push(position);
result.push(digit);
} }
// Reverse to get most significant digit first
result.reverse();
SortKey::from_numbers(result) SortKey::from_numbers(result)
} }
} }
@ -275,6 +390,7 @@ impl Iterator for EvenSpacingIterator {
#[derive(Debug)] #[derive(Debug)]
pub enum SortKeyParseError { pub enum SortKeyParseError {
InvalidCharacter(char), InvalidCharacter(char),
InvalidLength(usize),
} }
impl fmt::Display for SortKeyParseError { impl fmt::Display for SortKeyParseError {
@ -286,6 +402,11 @@ impl fmt::Display for SortKeyParseError {
c, c,
String::from_utf8_lossy(ALPHABET) String::from_utf8_lossy(ALPHABET)
), ),
SortKeyParseError::InvalidLength(len) => write!(
f,
"Invalid sort key length {}. Expected triangular number up to 36 (1, 3, 6, 10, 15, 21, 28, 36), then +8 per level (44, 52, 60, ...)",
len
),
} }
} }
} }
@ -296,11 +417,33 @@ impl FromStr for SortKey {
type Err = SortKeyParseError; type Err = SortKeyParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> { fn from_str(s: &str) -> Result<Self, Self::Err> {
let numbers = s let bytes = s.as_bytes();
.bytes() let mut numbers = Vec::new();
.map(|b| ALPHABET.iter().position(|&x| x == b).map(|pos| pos as u8)) let mut pos = 0;
.collect::<Option<Vec<u8>>>() let mut level = 0;
.ok_or_else(|| SortKeyParseError::InvalidCharacter(s.chars().next().unwrap()))?;
while pos < bytes.len() {
let num_chars = SortKey::chars_for_level(level);
if pos + num_chars > bytes.len() {
return Err(SortKeyParseError::InvalidLength(bytes.len()));
}
// Parse num_chars characters as a base-64 number
let mut value: u64 = 0;
for i in 0..num_chars {
let b = bytes[pos + i];
let digit = ALPHABET
.iter()
.position(|&x| x == b)
.ok_or(SortKeyParseError::InvalidCharacter(b as char))?;
value = value * 64 + digit as u64;
}
numbers.push(value);
pos += num_chars;
level += 1;
}
Ok(SortKey { numbers }) Ok(SortKey { numbers })
} }
} }
@ -311,13 +454,20 @@ mod tests {
use rand::rngs::StdRng; use rand::rngs::StdRng;
use rand::SeedableRng; use rand::SeedableRng;
/// Helper to create a SortKey from a slice of numbers
fn key(nums: &[u64]) -> SortKey {
SortKey::from_numbers(nums.to_vec())
}
#[test] #[test]
fn test_compare_lseq() { fn test_compare_lseq() {
let a = "a".parse::<SortKey>().unwrap(); // Single-character keys are level-0 values (0-63)
let b = "b".parse::<SortKey>().unwrap(); // "a" is position 38 in alphabet, "b" is 39
assert_eq!(a < b, true); let a = "-".parse::<SortKey>().unwrap(); // value 0
assert_eq!(b < a, false); let b = "0".parse::<SortKey>().unwrap(); // value 1
assert_eq!(a < a, false); assert!(a < b);
assert!(!(b < a));
assert!(!(a < a));
} }
#[test] #[test]
@ -335,8 +485,28 @@ mod tests {
#[test] #[test]
fn test_position_to_key() { fn test_position_to_key() {
// k=2 means 2 levels: level 0 (1 char) + level 1 (2 chars) = 3 chars total
// position 1 goes into level 1, with level 0 = 0
// [0, 1] = "-" + "-0" = "--0"
const K: u64 = 2; const K: u64 = 2;
assert_eq!(EvenSpacingIterator::position_to_key(K, 1).to_string(), "-0"); assert_eq!(
EvenSpacingIterator::position_to_key(K, 1).to_string(),
"--0"
);
// k=1 means just level 0 (1 char)
// position 1 = "0" (alphabet[1])
assert_eq!(
EvenSpacingIterator::position_to_key(1, 1).to_string(),
"0"
);
// k=2, position 4095 (max for level 1 = 64² - 1)
// [0, 4095] = "-" + "zz" = "-zz"
assert_eq!(
EvenSpacingIterator::position_to_key(2, 4095).to_string(),
"-zz"
);
} }
#[test] #[test]
@ -379,4 +549,85 @@ mod tests {
positions.len() positions.len()
); );
} }
/// Test the "go deeper" strategy for prepending before left-edge keys
#[test]
fn test_prepend_before_left_edge() {
let rng = StdRng::seed_from_u64(123);
let mut lseq = LSEQ::new(rng);
// Prepend before [0, 1] -> should get [0, 0, X]
let target = key(&[0, 1]);
let result = lseq.alloc(None, Some(&target));
assert!(result < target);
assert_eq!(result.numbers.len(), 3);
assert_eq!(result.numbers[0], 0);
assert_eq!(result.numbers[1], 0);
// Prepend before [0, 0, 1] -> should get [0, 0, 0, X]
let target = key(&[0, 0, 1]);
let result = lseq.alloc(None, Some(&target));
assert!(result < target);
assert_eq!(result.numbers.len(), 4);
// Prepend before [0, 0, 0, 1] -> should get [0, 0, 0, 0, X]
let target = key(&[0, 0, 0, 1]);
let result = lseq.alloc(None, Some(&target));
assert!(result < target);
assert_eq!(result.numbers.len(), 5);
}
/// Verify the ordering: [0, 0, X] < [0, 1] for any X
#[test]
fn test_left_edge_ordering() {
assert!(key(&[0, 0, 63]) < key(&[0, 1]));
assert!(key(&[0, 0, 1]) < key(&[0, 1]));
assert!(key(&[0, 0, 0, 63]) < key(&[0, 0, 1]));
}
/// Verify roundtrip: display -> parse -> display gives same result
#[test]
fn test_roundtrip_encoding() {
let cases = vec![
key(&[0]), // "-"
key(&[63]), // "z"
key(&[0, 0]), // "---"
key(&[0, 1]), // "--0"
key(&[0, 4095]), // "-zz"
key(&[1, 0]), // "0--"
key(&[0, 0, 0]), // "------"
key(&[0, 0, 1]), // "-----0"
key(&[0, 0, 262143]), // "---zzz"
];
for original in cases {
let s = original.to_string();
let parsed: SortKey = s.parse().expect(&format!("Failed to parse '{}'", s));
assert_eq!(
original.numbers, parsed.numbers,
"Roundtrip failed for {:?} -> '{}' -> {:?}",
original.numbers, s, parsed.numbers
);
}
}
/// Verify string encoding matches expected format
#[test]
fn test_string_encoding() {
// Level 0 only (1 char)
assert_eq!(key(&[0]).to_string(), "-");
assert_eq!(key(&[1]).to_string(), "0");
assert_eq!(key(&[63]).to_string(), "z");
// Level 0 + Level 1 (1 + 2 = 3 chars)
assert_eq!(key(&[0, 0]).to_string(), "---");
assert_eq!(key(&[0, 1]).to_string(), "--0");
assert_eq!(key(&[0, 64]).to_string(), "-0-"); // 64 = 1*64 + 0
assert_eq!(key(&[0, 4095]).to_string(), "-zz");
// Level 0 + Level 1 + Level 2 (1 + 2 + 3 = 6 chars)
assert_eq!(key(&[0, 0, 0]).to_string(), "------");
assert_eq!(key(&[0, 0, 1]).to_string(), "-----0");
assert_eq!(key(&[0, 0, 262143]).to_string(), "---zzz");
}
} }

View file

@ -1,9 +1,65 @@
const ALPHABET = const ALPHABET =
"-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz"; "-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz";
const MAX_LEVEL_EXPONENT = 8;
function maxValueForLevel(level: number): number {
const exp = Math.min(level + 1, MAX_LEVEL_EXPONENT);
return Math.pow(64, exp) - 1;
}
function charsForLevel(level: number): number {
return Math.min(level + 1, MAX_LEVEL_EXPONENT);
}
function idToNumbers(id: string): number[] { function idToNumbers(id: string): number[] {
const nums = id.split("").map((char) => ALPHABET.indexOf(char)); const numbers: number[] = [];
return nums; let pos = 0;
let level = 0;
while (pos < id.length) {
const numChars = charsForLevel(level);
if (pos + numChars > id.length) {
throw new Error(
`Invalid sort key length ${id.length}. Expected triangular number (1, 3, 6, 10, ...)`
);
}
let value = 0;
for (let i = 0; i < numChars; i++) {
const char = id[pos + i];
const digit = ALPHABET.indexOf(char);
if (digit === -1) {
throw new Error(
`Invalid character '${char}' in sort key. Expected characters from alphabet: ${ALPHABET}`
);
}
value = value * 64 + digit;
}
numbers.push(value);
pos += numChars;
level++;
}
return numbers;
}
function numbersToId(numbers: number[]): string {
let result = "";
for (let level = 0; level < numbers.length; level++) {
const numChars = charsForLevel(level);
const chars: string[] = [];
let v = numbers[level];
for (let i = 0; i < numChars; i++) {
chars.push(ALPHABET[v % 64]);
v = Math.floor(v / 64);
}
result += chars.reverse().join("");
}
return result;
} }
type Maybe<T> = T | null; type Maybe<T> = T | null;
@ -13,7 +69,6 @@ function isNone<T>(value: Maybe<T>): value is null {
} }
export class LSEQ { export class LSEQ {
// true = allocate near min, false = allocate near max
private strategies: boolean[]; private strategies: boolean[];
private random: () => number; private random: () => number;
@ -22,50 +77,157 @@ export class LSEQ {
this.strategies = [random() < 0.5]; this.strategies = [random() < 0.5];
} }
public alloc(before: Maybe<string>, after: Maybe<string>): string { private genRange(min: number, max: number): number {
// Convert to numeric arrays, using boundary values for null return min + Math.floor(this.random() * (max - min));
const p = isNone(before) ? [0] : idToNumbers(before); }
const q = isNone(after) ? [63] : idToNumbers(after);
alloc(before: Maybe<string>, after: Maybe<string>): string {
const p = isNone(before) ? [] : idToNumbers(before);
const q = isNone(after) ? [] : idToNumbers(after);
// Walk through digits looking for space
let depth = 0; let depth = 0;
const result = []; const result: number[] = [];
// eslint-disable-next-line no-constant-condition
while (true) { while (true) {
const pVal = depth < p.length ? p[depth] : 0; const pVal = p[depth] ?? 0;
const qVal = depth < q.length ? q[depth] : 63; const qUpper = q[depth];
const levelMax = maxValueForLevel(depth);
const interval = qVal - pVal; const minAlloc = pVal + 1;
const maxAlloc =
qUpper !== undefined ? Math.max(0, qUpper - 1) : levelMax;
// If we have space between values at this depth if (minAlloc <= maxAlloc) {
if (interval > 1) { const range = maxAlloc - minAlloc + 1;
// Pick a value in the available range const offset = this.genRange(0, range);
const range = interval - 1; const newValue = this.strategies[depth]
const addVal = 1 + Math.floor(this.random() * range); ? minAlloc + offset
let newValue; : maxAlloc - offset;
if (this.strategies[depth]) {
newValue = pVal + addVal;
} else {
newValue = qVal - addVal;
}
// Take the prefix from p up to depth and append our new value
result.push(newValue); result.push(newValue);
return numbersToId(result);
return result.map((n) => ALPHABET[n]).join("");
} }
result.push(pVal); result.push(pVal);
// If values are the same or adjacent at this depth,
// continue to next depth
depth++; depth++;
if (depth > this.strategies.length) { if (depth >= this.strategies.length) {
this.strategies.push(this.random() < 0.5); this.strategies.push(this.random() < 0.5);
} }
} }
} }
} }
const USABLE_SPACE: number[] = [
64 - 1,
4096 - 1,
262144 - 1,
16777216 - 1,
1073741824 - 1,
68719476736 - 1,
4398046511104 - 1,
281474976710656 - 1,
];
export class EvenSpacingIterator implements Iterator<number> {
private remainingItems: number;
private spaceSize: number;
private nextItem: number;
private stepSizeInteger: number;
private stepSizeError: number;
private errorAccumulator: number;
private constructor(
remainingItems: number,
spaceSize: number,
stepSizeInteger: number,
stepSizeError: number
) {
this.remainingItems = remainingItems;
this.spaceSize = spaceSize;
this.nextItem = 1;
this.stepSizeInteger = stepSizeInteger;
this.stepSizeError = stepSizeError;
this.errorAccumulator = 0;
}
static create(totalItems: number): { k: number; iterator: EvenSpacingIterator } {
if (totalItems === 0) {
throw new Error("Too many items to allocate");
}
let k = 0;
let spaceSize = 0;
for (let index = 0; index < USABLE_SPACE.length; index++) {
const size = USABLE_SPACE[index];
if (size >= totalItems) {
k = index + 1;
spaceSize = size;
break;
}
}
if (k === 0) {
throw new Error("Too many items to allocate");
}
const stepSize = spaceSize / totalItems;
const stepSizeInteger = Math.floor(stepSize);
const stepSizeError = stepSize - stepSizeInteger;
return {
k,
iterator: new EvenSpacingIterator(
totalItems,
spaceSize,
stepSizeInteger,
stepSizeError
),
};
}
static positionToKey(k: number, position: number): string {
const result: number[] = [];
for (let i = 0; i < k - 1; i++) {
result.push(0);
}
if (k > 0) {
result.push(position);
}
return numbersToId(result);
}
next(): IteratorResult<number> {
if (this.remainingItems === 0) {
return { done: true, value: undefined };
}
if (this.nextItem > this.spaceSize) {
return { done: true, value: undefined };
}
const currentPosition = this.nextItem;
this.remainingItems--;
this.nextItem += this.stepSizeInteger;
this.errorAccumulator += this.stepSizeError;
if (this.errorAccumulator >= 1.0) {
this.nextItem++;
this.errorAccumulator -= 1.0;
}
return { done: false, value: currentPosition };
}
[Symbol.iterator](): Iterator<number> {
return this;
}
}
export function compareLSEQ(a: string, b: string): number { export function compareLSEQ(a: string, b: string): number {
if (a === b) return 0; if (a === b) return 0;
return a < b ? -1 : 1; return a < b ? -1 : 1;