diff --git a/.gitignore b/.gitignore index 314acf6..08b0fa7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # TypeScript -typescript/node_modules/ +node_modules/ typescript/dist/ typescript/*.tsbuildinfo typescript/.nyc_output/ @@ -44,3 +44,5 @@ pids .env.test.local .env.production.local .env.local + +conformance-tests/genfiles/ diff --git a/conformance-tests/README.md b/conformance-tests/README.md new file mode 100644 index 0000000..31d3de4 --- /dev/null +++ b/conformance-tests/README.md @@ -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. diff --git a/conformance-tests/generator/Cargo.lock b/conformance-tests/generator/Cargo.lock new file mode 100644 index 0000000..1425a8d --- /dev/null +++ b/conformance-tests/generator/Cargo.lock @@ -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", +] diff --git a/conformance-tests/generator/Cargo.toml b/conformance-tests/generator/Cargo.toml new file mode 100644 index 0000000..51e8eab --- /dev/null +++ b/conformance-tests/generator/Cargo.toml @@ -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" diff --git a/conformance-tests/generator/src/main.rs b/conformance-tests/generator/src/main.rs new file mode 100644 index 0000000..a0547cb --- /dev/null +++ b/conformance-tests/generator/src/main.rs @@ -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, +} + +impl RecordingRng { + fn new(seed: u64) -> Self { + RecordingRng { + inner: StdRng::seed_from_u64(seed), + recorded: Vec::new(), + } + } + + fn take_recorded(&mut self) -> Vec { + 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 { + // 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, &mut Vec) -> Vec, +) -> Scenario { + let mut lseq = LSEQ::new(RecordingRng::new(seed)); + let mut state: Vec = 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::().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::().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::().unwrap()) + } else { + None + }; + let after_key = if idx < state.len() { + Some(state[idx].parse::().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::().unwrap(); + let after_key = state[1].parse::().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::().unwrap(); + let after_key = state[1].parse::().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::().unwrap(); + let after_key = state[1].parse::().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); + } +} diff --git a/conformance-tests/generator/src/model.rs b/conformance-tests/generator/src/model.rs new file mode 100644 index 0000000..9919987 --- /dev/null +++ b/conformance-tests/generator/src/model.rs @@ -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, + pub rng: Vec, + pub operations: Vec, +} + +/// 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(&self, serializer: S) -> Result + 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() + } +} diff --git a/conformance-tests/genfiles/.gitkeep b/conformance-tests/genfiles/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/conformance-tests/runners/typescript/package-lock.json b/conformance-tests/runners/typescript/package-lock.json new file mode 100644 index 0000000..ccbe04c --- /dev/null +++ b/conformance-tests/runners/typescript/package-lock.json @@ -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" + } + } + } +} diff --git a/conformance-tests/runners/typescript/package.json b/conformance-tests/runners/typescript/package.json new file mode 100644 index 0000000..c545221 --- /dev/null +++ b/conformance-tests/runners/typescript/package.json @@ -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" + } +} diff --git a/conformance-tests/runners/typescript/src/runner.ts b/conformance-tests/runners/typescript/src/runner.ts new file mode 100644 index 0000000..27600dd --- /dev/null +++ b/conformance-tests/runners/typescript/src/runner.ts @@ -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(); diff --git a/conformance-tests/runners/typescript/tsconfig.json b/conformance-tests/runners/typescript/tsconfig.json new file mode 100644 index 0000000..dc14549 --- /dev/null +++ b/conformance-tests/runners/typescript/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"] +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 4aa1fb5..79acfee 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -10,11 +10,39 @@ use std::str::FromStr; 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; +} + +/// Blanket implementation for anything that implements rand::Rng +impl 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 { + Rng::gen_range(self, range) + } +} + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] #[cfg_attr(feature = "serde", derive(Serialize))] #[cfg_attr(feature = "serde", serde(into = "String"))] pub struct SortKey { - numbers: Vec, + /// 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, } #[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 { - pub fn from_numbers(numbers: Vec) -> Self { + pub fn from_numbers(numbers: Vec) -> Self { 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 for Vec { - fn from(key: SortKey) -> Vec { +impl From for Vec { + fn from(key: SortKey) -> Vec { key.numbers } } @@ -62,8 +110,8 @@ impl From for String { } } -impl AsRef<[u8]> for SortKey { - fn as_ref(&self) -> &[u8] { +impl AsRef<[u64]> for SortKey { + fn as_ref(&self) -> &[u64] { &self.numbers } } @@ -76,8 +124,21 @@ impl From for SortKey { impl fmt::Display for SortKey { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - for &n in &self.numbers { - write!(f, "{}", ALPHABET[n as usize] as char)?; + for (level, &value) in self.numbers.iter().enumerate() { + 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(()) } @@ -85,52 +146,106 @@ impl fmt::Display for SortKey { #[allow(dead_code)] #[derive(Debug)] -pub struct LSEQ { +pub struct LSEQ { strategies: Vec, rng: R, } #[allow(dead_code)] -impl LSEQ { +impl LSEQ { pub fn new(mut rng: R) -> Self { let strategies = vec![rng.gen_bool(0.5)]; LSEQ { strategies, rng } } - pub fn alloc(&mut self, before: Option<&SortKey>, after: Option<&SortKey>) -> SortKey { - // Convert to numeric arrays, using boundary values for null - let p = before.map_or(vec![0], |s| s.numbers.clone()); - let q = after.map_or(vec![63], |s| s.numbers.clone()); + /// Consume the LSEQ and return the inner RNG + pub fn take_rng(self) -> R { + self.rng + } + + /// 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 result = Vec::new(); + let mut result: Vec = Vec::new(); loop { - let p_val = if depth < p.len() { p[depth] } else { 0 }; - let q_val = if depth < q.len() { q[depth] } else { 63 }; + let p_val = p.get(depth).copied().unwrap_or(0); + 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 - if interval > 1 { - // Pick a value in the available range - let range = interval - 1; - let add_val = 1 + self.rng.gen_range(0..range) as u8; + // Maximum allocatable (inclusive): + // - With upper bound: one below it + // - Without upper bound (after=None): full range for this level + let max_alloc = q_upper.map_or(level_max, |q| q.saturating_sub(1)); + + 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] { - p_val + add_val + min_alloc + offset } 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); return SortKey::from_numbers(result); } + + // Descend to next level result.push(p_val); - // If values are the same or adjacent at this depth, - // continue to next depth depth += 1; if depth >= self.strategies.len() { self.strategies.push(self.rng.gen_bool(0.5)); @@ -165,21 +280,21 @@ pub struct EvenSpacingIterator { } impl EvenSpacingIterator { - // Static table of (64^k - 2) values for k from 1 to 9 - // We subtract 2 from each space size because we need to reserve two boundary positions: - // 1. Position 0 (represented by "-") is reserved as the lower boundary - // 2. Position 63 (represented by "z") is reserved as the upper boundary - // This ensures we can always insert elements at the very beginning or end of the sequence - const USABLE_SPACE: [usize; 9] = [ - 64 - 2, // 64^1 - 2 - 4096 - 2, // 64^2 - 2 - 262144 - 2, // 64^3 - 2 - 16777216 - 2, // 64^4 - 2 - 1073741824 - 2, // 64^5 - 2 - 68719476736 - 2, // 64^6 - 2 - 4398046511104 - 2, // 64^7 - 2 - 281474976710656 - 2, // 64^8 - 2 - 18014398509481984 - 2, // 64^9 - 2 + // Static table of (64^k - 1) values for k from 1 to 8 + // We subtract 1 because we reserve only the lower boundary (position 0, all "-"s). + // Position 0 cannot be used because nothing can be lexicographically less than it. + // The upper boundary (all "z"s) IS usable because we can always insert after it + // by extending: "zzz" < "zzza" lexicographically (prefix comparison). + // Capped at 64^8 = 2^48 for JavaScript number compatibility (max safe: 2^53). + const USABLE_SPACE: [usize; 8] = [ + 64 - 1, // 64^1 - 1 + 4096 - 1, // 64^2 - 1 + 262144 - 1, // 64^3 - 1 + 16777216 - 1, // 64^4 - 1 + 1073741824 - 1, // 64^5 - 1 + 68719476736 - 1, // 64^6 - 1 + 4398046511104 - 1, // 64^7 - 1 + 281474976710656 - 1, // 64^8 - 1 ]; 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 { 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 - for _ in 0..k { - // SAFETY: digit is guaranteed to be in bounds because: - // 1. digit = pos % base where base is 64 - // 2. ALPHABET has exactly 64 elements - // Therefore digit as u64 will always be 0-63 - let digit = (pos % BASE) as u8; - pos /= BASE; - result.push(digit); + // Levels 0 through k-2 are 0 + for _ in 0..k.saturating_sub(1) { + result.push(0); + } + + // Level k-1 contains the position + if k > 0 { + result.push(position); } - // Reverse to get most significant digit first - result.reverse(); SortKey::from_numbers(result) } } @@ -275,6 +390,7 @@ impl Iterator for EvenSpacingIterator { #[derive(Debug)] pub enum SortKeyParseError { InvalidCharacter(char), + InvalidLength(usize), } impl fmt::Display for SortKeyParseError { @@ -286,6 +402,11 @@ impl fmt::Display for SortKeyParseError { c, 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; fn from_str(s: &str) -> Result { - let numbers = s - .bytes() - .map(|b| ALPHABET.iter().position(|&x| x == b).map(|pos| pos as u8)) - .collect::>>() - .ok_or_else(|| SortKeyParseError::InvalidCharacter(s.chars().next().unwrap()))?; + let bytes = s.as_bytes(); + let mut numbers = Vec::new(); + let mut pos = 0; + let mut level = 0; + + 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 }) } } @@ -311,13 +454,20 @@ mod tests { use rand::rngs::StdRng; 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] fn test_compare_lseq() { - let a = "a".parse::().unwrap(); - let b = "b".parse::().unwrap(); - assert_eq!(a < b, true); - assert_eq!(b < a, false); - assert_eq!(a < a, false); + // Single-character keys are level-0 values (0-63) + // "a" is position 38 in alphabet, "b" is 39 + let a = "-".parse::().unwrap(); // value 0 + let b = "0".parse::().unwrap(); // value 1 + assert!(a < b); + assert!(!(b < a)); + assert!(!(a < a)); } #[test] @@ -335,8 +485,28 @@ mod tests { #[test] 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; - 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] @@ -379,4 +549,85 @@ mod tests { 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"); + } } diff --git a/typescript/src/index.ts b/typescript/src/index.ts index 2121353..06267c4 100644 --- a/typescript/src/index.ts +++ b/typescript/src/index.ts @@ -1,9 +1,65 @@ const ALPHABET = "-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[] { - const nums = id.split("").map((char) => ALPHABET.indexOf(char)); - return nums; + const numbers: number[] = []; + 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 | null; @@ -13,7 +69,6 @@ function isNone(value: Maybe): value is null { } export class LSEQ { - // true = allocate near min, false = allocate near max private strategies: boolean[]; private random: () => number; @@ -22,50 +77,157 @@ export class LSEQ { this.strategies = [random() < 0.5]; } - public alloc(before: Maybe, after: Maybe): string { - // Convert to numeric arrays, using boundary values for null - const p = isNone(before) ? [0] : idToNumbers(before); - const q = isNone(after) ? [63] : idToNumbers(after); + private genRange(min: number, max: number): number { + return min + Math.floor(this.random() * (max - min)); + } + + alloc(before: Maybe, after: Maybe): string { + const p = isNone(before) ? [] : idToNumbers(before); + const q = isNone(after) ? [] : idToNumbers(after); - // Walk through digits looking for space let depth = 0; - const result = []; - // eslint-disable-next-line no-constant-condition + const result: number[] = []; + while (true) { - const pVal = depth < p.length ? p[depth] : 0; - const qVal = depth < q.length ? q[depth] : 63; + const pVal = p[depth] ?? 0; + 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 (interval > 1) { - // Pick a value in the available range - const range = interval - 1; - const addVal = 1 + Math.floor(this.random() * range); - let newValue; - if (this.strategies[depth]) { - newValue = pVal + addVal; - } else { - newValue = qVal - addVal; - } - - // Take the prefix from p up to depth and append our new value + if (minAlloc <= maxAlloc) { + const range = maxAlloc - minAlloc + 1; + const offset = this.genRange(0, range); + const newValue = this.strategies[depth] + ? minAlloc + offset + : maxAlloc - offset; result.push(newValue); - - return result.map((n) => ALPHABET[n]).join(""); + return numbersToId(result); } + result.push(pVal); - // If values are the same or adjacent at this depth, - // continue to next depth depth++; - if (depth > this.strategies.length) { + if (depth >= this.strategies.length) { 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 { + 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 { + 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 { + return this; + } +} + export function compareLSEQ(a: string, b: string): number { if (a === b) return 0; return a < b ? -1 : 1;