A Rust implementation of fractional indexing.

Overview

fractional_index

crates.io docs.rs wokflow state

This crate implements fractional indexing, a term coined by Figma in their blog post Realtime Editing of Ordered Sequences.

Specifically, this crate provides a type called ZenoIndex. A ZenoIndex acts as a “black box” that has no accessor functions, can only be used by comparing it to another ZenoIndex, and can only be constructed from a default constructor or by reference to an existing ZenoIndex.

This is useful as a key in a BTreeMap when we want to be able to arbitrarily insert or re-order elements in a collection, but don't actually care what the key is.

For an ordered sequence data structure built atop this implementation, see the List implementation of Aper.

Introduction

Given a key-value store that is sorted by key, we can construct an ordered list by assigning each value an arbitrary ascending key in some ordered type. However, our ability to perform an insert to an arbitrary position in the list will depend on our ability to construct a key between any two adjacent values.

A naive approach to this is to use a floating-point number as the key. To find a key between two adjacent values, we could average those two values. However, this runs into numerical precision issues where, as the gap between adjacent values becomes smaller, it becomes impossible to find a new value that is strictly between two others. (If you squint, this is like the line-numbering problem that plagued BASIC developers.)

One solution to this is to replace the floats with arbitrary-precision fractions, with which you can always represent a number strictly between two other (non-equal) numbers. Aside from polluting your data structure code with unnecessary arithmatic, the downside is that the room needed to store this representation tends to grow with repeated averaging. This happens in decimal, too: suppose we need to find a value between 0.76 and 0.63. Averaging gives us 0.695, which requires an extra digit to represent than the original two numbers. But for the purpose of ordering, we really just need a number x such that 0.63 < x < 0.76. We would be just as happy to use 0.7, which requires fewer digits than the original numbers to represent.

At the core of a ZenoIndex is an arbitrary-precision floating-point number, but by limiting the interface to comparisons and providing weaker semantics, the implementation is free to make optimizations akin to the example above (albeit in base-256) in order to optimize for space.

Figma's post sketches the approach they use, which is based on a string representation of fractional numbers, and some of the implementation details are left up to the reader. This crate attempts to formalize the math behind the approach and provide a clean interface that abstracts the implementation details away from the crate user.

Zeno Index

To differentiate between the concept of fractional indexing and the mathematical implementation used here, I have called the mathematical implementation I used a Zeno index after the philosopher Zeno of Elea, whose dichotomy paradox has fun parallels to the implementation.

This crate exposes the ZenoIndex struct. The things you can do with a ZenoIndex are, by design, very limited:

  • Construct a default ZenoIndex (Default implementation).
  • Given any ZenoIndex, construct another ZenoIndex before or after it.
  • Given any two ZenoIndexes, construct a ZenoIndex between them.
  • Compare two ZenoIndexes for order and equality.
  • Serialize and deserialize using serde (with the serde crate feature, which is enabled by default).

Notably, ZenoIndexes are opaque: even though they represent a number, they don't provide an interface for accessing that number directly. Additionally, they don't provide guarantees about representation beyond what is exposed by the interface, which gives the implementation room to optimize for space.

Examples

use fractional_index::ZenoIndex;

fn main() {
    // Unless you already have a ZenoIndex, the only way to obtain one is using
    // the default constructor.
    let idx = ZenoIndex::default();

    // Now that we have a ZenoIndex, we can construct another relative to it.
    let idx2 = ZenoIndex::new_after(&idx);

    assert!(idx < idx2);

    // Now that we have two ZenoIndexes, we can construct another between them.
    // new_between returns an Option, since it is impossible to construct a
    // value between two values if they are equal (it also returns None if
    // the first argument is greater than the second).
    let idx3 = ZenoIndex::new_between(&idx, &idx2).unwrap();

    assert!(idx < idx3);
    assert!(idx3 < idx2);

    let idx4 = ZenoIndex::new_before(&idx);

    assert!(idx4 < idx);
    assert!(idx4 < idx2);
    assert!(idx4 < idx3);

    // It is legal to construct an index between any two values, however,
    // the only guarantees with regards to ordering are that:
    // - The new value will compare appropriately with the values used in its
    //   construction.
    // - Comparisons with other values are an undefined implementation detail,
    //   but comparisons will always be transitive. That said, it is possible
    //   (likely, even) to construct two values which compare as equal, so
    //   care must be taken to account for that.
    let idx5 = ZenoIndex::new_between(&idx4, &idx2).unwrap();
}

Considerations

All operations on a ZenoIndex are deterministic, which means that if you construct a ZenoIndex by reference to the same other ZenoIndexes, you will get the same ZenoIndex back. Without care, this may mean that an insert replaces an existing value in your data structure. The right solution to this will depend on your use-case, but options include:

  • Only ever use new_between for keys that are adjacent in your data structure, only use new_before on the least key in your data structure, and only use new_after on the greatest key. This way, you will never construct a ZenoIndex that is already a key in your data structure.
  • When inserting into your data structure, look for a value that already has that key; if it does, transform the key by calling new_between with its adjacent key.

Implementation

One of the goals of this crate is to provide an opaque interface that you can use without needing to study the implementation, but if you're interested in making changes to the implementation or just curious, this section describes the implementation.

Representation

Each ZenoIndex is backed by a (private) Vec , i.e. a sequence of bytes. Mathematically, the numeric value represented by this sequence of bytes is:

Where n is the number of bytes and vi is the value of the ith byte (zero-indexed).

The right term alone would be sufficient as an arbitrary-precision fraction representation, but the left term serves several purposes:

  • It makes it impossible to represent zero. This is necessary because we always need to be able to represent a value between a ZenoIndex and zero.
  • It ensures that no two differing sequences of bytes represent the same number (without it, trailing zeros could be added without changing the represented numeric value).
  • It removes the “floor bias” that would bias the representation towards zero. In particular, it means that an empty sequence of bytes represents ½ instead of 0.

Comparisons

To compare two numbers using this representation, we iterate through them byte-wise. If they differ at a given index, we can simply compare those values to determine the order.

Things get more complicated when one string of bytes is a prefix of the other. Without the first term, our representation would be lexicographic and we could say the shorter one comes before the longer one. Due to the first term, though, the longer one could either be before or after. So we check the following byte to see if it is at most 127 (in which case the longer string comes before its prefix) or not (in which case the longer string comes after).

To simpilify the code, we take advantage of some properties of infinite series. The representation above is equivalent to

which we can rewrite as

by defining v'i as the ith byte when i < n or 127.5 if in.

Since it's impossible to represent 127.5 as a byte, we convert bytes into an enum representation where they can take either a standard u8 byte value, or the “magic” value of 127.5 (the word magic here is used more in the wizard sense than the computational one). The comparison operator is implemented on this enum such that the magic value compares as greater than 127 but less than 128.

Construction

There are three ways to construct a ZenoIndex:

  • From nothing: ZenoIndex implements Default. Under the hood, this constructs a Zeno Index backed by an empty byte string -- i.e., equivalent to 0.5.
  • In relation to one other ZenoIndex (either before or after). We walk through the reference ZenoIndex's byte string, and see if we can increment or decrement it. If we get to the end, we add another byte to the end to get a new ZenoIndex with the desired order.
  • In between two other ZenoIndexes. We find the first byte index at which they differ. If the values of the index at which they differ fall on different sides of 127.5, we use the prefix that both share as the representation of the newly constructed ZenoIndex. Otherwise, we look for a byte value between the two, and extend the representation by a byte if that isn't possible.

These are not the only way to satisfy the public interface of ZenoIndex, so they represent certain design considerations. In particular, the decision to increment or decrement by 1 instead of averaging with 0 or 255 comes from the fact that we expect a new item to often come directly after the last new item. In the limit case of a data structure that is only ever appended to, this allows us to grow the size of the underlying representation by a byte only once every 64 new items, instead of every 8 if we averaged.

Likewise, the decision to check whether two differing bytes straddle 127.5 is not strictly necessary, but allows us to find opportunities to use a smaller underlying representation in cases where items have been removed from a data structure. If we instead were to just average the two values, it would be likely that a collection of elements would grow in representation size over successive additions and deletions, even if the number of elements stayed constant.

You might also like...
Implementation of the WebUSB specification in Rust.

Implementation of the WebUSB specification in Rust.

A Conway's Game of Life implementation in Rust & WASM
A Conway's Game of Life implementation in Rust & WASM

Rust Wasm Game of Life This repo contains an example implementation of Conway's Game of Life in Rust and WebAssembly. How to use You should have wasm-

KERI implementation in RUST, current development lead by DIF

KERIOX Introduction Features Introduction KERIOX is an open source Rust implementation of the Key Event Receipt Infrastructure (KERI) , a system desig

A rust implementation of the reverse-engineered Glorious mouse protocol

gloryctl This project is an implementation of the vendor-specific HID protocol in use by Glorious mice used to configure parameters such as DPI profil

Rust implementation of the Edge IoT framework

A Rust-based implementation of Edge-rs for the Raspberry Pi Pico Getting started For more details see the following article on getting started for get

The rust implementation of IPLD schemas and some associated functionalities.

Linked Data IPLD schemas and what to do with them. Beacon Directory of user content and metadata. Since this object should not change it can be used a

Rust implementation of Project Fluent

Fluent fluent-rs is a collection of Rust crates implementing Project Fluent. The crates perform the following functions: fluent Umbrella crate combini

DPDK's rte_ring implementation in Rust

rte_ring Ring library from DPDK ported to Rust. Based on DPDK 21.11.0. Features FIFO Maximum size is fixed, the pointers are stored in a table Lockles

A rust bencode encoding/decoding implementation backed by serde.

Bende A rust bencode encoding/decoding implementation backed by serde. About This is one of a few bencode implementations available for rust. Though t

Comments
  • Use in databases

    Use in databases

    Hi, this crate would be very useful for me for an ordered list stored in a database. But in order to use it for that, I would need a couple extra qualities:

    • Direct access to the underlying sequence of bytes to serialize to the database. I could use Serde, but it would be quite some effort and less efficient than accessing the bytes directly.
    • Construction from an underlying sequence of bytes to deserialize from the database. Again, while Serde is an option it suffers from the same drawbacks as above.
    • Some stability guarantees about the underlying implementation. This could be implemented by somehow versioning ZenoIndexes from different versions of the crate, if you think it's possible the actual implementation will still evolve.

    Would it be possible to provide these? I would be happy to provide a PR if so.

    opened by SabrinaJewson 4
  • How to derive a string from ZenoIndex that works with basic lexographic sorting?

    How to derive a string from ZenoIndex that works with basic lexographic sorting?

    The readme contains this section:

    Things get more complicated when one string of bytes is a prefix of the other. Without the first term, our representation would be lexicographic and we could say the shorter one comes before the longer one. Due to the first term, though, the longer one could either be before or after. So we check the following byte to see if it is at most 127 (in which case the longer string comes before its prefix) or not (in which case the longer string comes after).

    From this, I gather that simply converting the bytes to a string and then doing a < or > operation will not allow you to reliably discern the order of two entries. For my use-case, this is a significant negative, because it means that "unsophisticated clients" that receive the order-keys become unable to simply do string comparisons to determine the ordering -- requiring that every third-party client reading data from my GraphQL api be using some version of the fractional_index library in their dependencies, even if all they want to do is check the order of a list of returned entries.

    So I figured I would raise this issue, to ask two questions:

    1. Is there some straightforward way to convert ZenoIndex instances into strings that can be lexicographically sorted?
    2. If not, is there some "way to use" the library (eg. a wrapper function one could construct) that would avoid producing ZenoIndex instances whose string-representations cannot be lexicographically sorted?

    I will likely try to build a solution for either 1 or 2 soon (after getting a better handle on the generation/comparison approach being used), but I figured I would mention it here first in case you know of a simple solution to it that can simply be plugged in.

    opened by Venryx 3
Owner
null
Count and convert between different indexing schemes on utf8 string slices

Str Indices Count and convert between different indexing schemes on utf8 string slices. The following schemes are currently supported: Chars (or "Unic

Nathan Vegdahl 11 Dec 25, 2022
Rust implementation of the legacy Master Server Query Protocol

msq-rs Rust library implementation of the legacy Master Server Query Protocol. Documentation crates.io Repository Release Notes Usage Add this to your

mtcw 6 Nov 20, 2022
🦀 Rust-based implementation of a Snowflake Generator which communicates using gRPC

Clawflake Clawflake is a Rust application which implements Twitter Snowflakes and communicates using gRPC. Snowflake ID numbers are 63 bits integers s

n1c00o 5 Oct 31, 2022
Re-implementation of Panda Doodle in Rust targetting WASM, a mobile game originally written in C++

Description This is the source code of my game Panda Doodle, which can be played at https://pandadoodle.lucamoller.com/ (it's best playable on touch s

null 79 Dec 5, 2022
2D Predictive-Corrective Smoothed Particle Hydrodynamics (SPH) implementation in Rust with WASM + WebGL

pcisph-wasm 2D Predictive-Corrective Smoothed Particle Hydrodynamics (SPH) implementation in Rust with WASM + WebGL Reimplementation of my previous Ru

Lucas V. Schuermann 46 Dec 17, 2022
Pure rust implementation of jq

XQ JQ reimplemented purely in Rust. Caution This program is under development. You probably want to use the original implementation of jq, or pure Go

null 181 Jan 4, 2023
A pure Rust PLONK implementation using arkworks as a backend.

PLONK This is a pure Rust implementation of the PLONK zk proving system Usage use ark_plonk::prelude::*; use ark_ec::bls12::Bls12; use rand_core::OsRn

rust-zkp 201 Dec 31, 2022
A Bancho implementation made in Rust for the *cursed* stack.

cu.rs A Bancho implementation made in Rust for the cursed stack. THIS PROJECT IS REALLY UNFINISHED AND IN ITS EARLY STAGES A drag and drop replacement

RealistikOsu! 5 Feb 1, 2022
Golang like WaitGroup implementation for sync/async Rust.

wg Golang like WaitGroup implementation for sync/async Rust.

Al Liu 8 Dec 6, 2022
Hexdump implementation in Rust

Minimalistic hexdump implementation in Rust

null 1 Oct 25, 2021