RadixForge 0.1.0-preview.1

This is a prerelease version of RadixForge.
There is a newer version of this package available.
See the version list below for details.
dotnet add package RadixForge --version 0.1.0-preview.1
                    
NuGet\Install-Package RadixForge -Version 0.1.0-preview.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="RadixForge" Version="0.1.0-preview.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="RadixForge" Version="0.1.0-preview.1" />
                    
Directory.Packages.props
<PackageReference Include="RadixForge" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add RadixForge --version 0.1.0-preview.1
                    
#r "nuget: RadixForge, 0.1.0-preview.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package RadixForge@0.1.0-preview.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=RadixForge&version=0.1.0-preview.1&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=RadixForge&version=0.1.0-preview.1&prerelease
                    
Install as a Cake Tool

RadixForge

A concurrent, mutable, multi-value adaptive radix index for .NET 10.

RadixForge turns strings, integers, bytes, and domain keys into a compact byte-oriented search tree for fast exact and prefix lookup. The name combines radix—the branching alphabet used by the index—with forge—the focus on a practical, production-engineered collection.

This is a .NET 10 library for fast exact and prefix indexing over strings, integers, raw bytes, and custom key types. It combines a path-compressed adaptive radix tree with production features that lightweight prefix maps usually omit:

  • multiple values and duplicate values under one key;
  • concurrent readers and writers;
  • explicit removal by key and value;
  • constant-time subtree counts after locating a prefix;
  • allocation-free visitor queries in direct mode;
  • stable snapshot results and lock-held lazy enumeration;
  • direct and compactable buffered mutation modes;
  • canonical codecs for ordinal strings and fixed-width integers;
  • a public codec contract for application-specific keys.

The package is an in-memory secondary index, not a full-text search engine and not a replacement for every .NET collection.

Quick start

using RadixForge;

using var names = new RadixIndex<string, Person>(
    RadixKeyCodecs.Utf16Ordinal);

names.Add("Ernie", new Person(1, "Ernie"));
names.Add("Ernest", new Person(2, "Ernest"));
names.Add("Ernie", new Person(3, "Ernie"));

IReadOnlyList<Person> exact = names.FindExact("Ernie"); // two values
IReadOnlyList<Person> prefix = names.FindByPrefix("Er"); // three values
int count = names.CountByPrefix("Er");                  // no result list

var totalIds = 0;
names.VisitByPrefix("Er", person => totalIds += person.Id);

public sealed record Person(int Id, string Name);

The package targets .NET 10 only. Once the first public preview is published:

dotnet add package RadixForge --prerelease

Documentation

Start with the guide that matches what you are building:

Perspective Guide
New user Installation, first index, and core concepts
Application developer String prefixes, autocomplete, commands, and routes
Database or domain developer Mutable multi-value secondary indexes
Systems developer Integer partitions, bytes, and binary key spaces
Library integrator Custom codecs for domain-specific keys
State owner Direct/buffered mutation, compaction, and lifetime
Concurrent or low-allocation caller Snapshots, visitors, lazy enumeration, and locks
Performance engineer Choosing RadixForge and interpreting the benchmarks
API user Public API cheat sheet

The documentation index also provides a task-oriented route through all examples.

Why use it?

Use RadixIndex<TKey,TItem> when an application needs to maintain a live starts-with index rather than repeatedly scan a collection:

nameIndex.Remove(oldName, person);
nameIndex.Add(person.Name, person);

The index is particularly suited to database-like secondary indexes, command or route lookup, symbol tables, autocomplete candidate retrieval, hierarchical byte keys, and high-byte numeric partitions.

Choose a different structure when:

  • lookup is exact-only and one value belongs to each key: use Dictionary or FrozenDictionary;
  • data is immutable and sorted range scans dominate: use a sorted contiguous representation;
  • queries require contains, fuzzy matching, tokenization, stemming, ranking, wildcards, or linguistic collation: use a search engine;
  • numeric queries are arbitrary intervals rather than encoded high-byte partitions: use a range-oriented index.

Measured performance

The following results use 50,000 distinct ordinal string keys with one int per key on .NET 10.0.9. They compare the production RadixIndex API with the pinned NuGet packages PrefixLookup 0.1.17 and KTrie 3.0.1. Lower is better.

Operation Production radix PrefixLookup KTrie
Exact count hit 304.6 ns 241.7 ns 389.1 ns
Visit prefix, 50 values 672.5 ns 843.1 ns 5,437 ns
Visit prefix, 5,000 values 55.72 µs 64.93 µs 880.05 µs
Build 50,000 values 12.23 ms 28.67 ms 110.65 ms
Build allocation 9.0 MB 16.73 MB 47.08 MB
Retained memory per value 171.2 B 225.6 B 925.0 B
Replace 5,000 values 2.91 ms no removal API tested 20.09 ms
Replacement allocation 1.21 MB n/a 10.07 MB

The radix visitor allocated no managed memory in either measured prefix workload. PrefixLookup remains about 21% faster for this exact one-value hit; Dictionary<string,int> measured 17.8 ns and remains the correct exact-only control. The production feature layers also make the radix slower than the minimal architecture prototype, which is why both sets of numbers remain in the repository.

These measurements are not universal promises. Key distribution, result size, codec cost, value multiplicity, mutation pattern, CPU, runtime, and query API all matter. See PRODUCTION-BENCHMARKS.md for the methodology and reproduction commands.

Architecture

Keys are encoded into canonical bytes and stored in one path-compressed adaptive radix tree. A node stores a shared byte segment instead of one object per character. Child storage grows with actual fan-out:

0 children -> no child allocation
1..4       -> Node4
5..16      -> Node16
17..48     -> Node48
49..256    -> Node256

A one-value terminal stores its value inline. It allocates a value list only when a second value is added. Every node maintains its visible subtree value count, making CountExact and CountByPrefix independent of the number of returned values after the key path is found.

Path splitting, pruning, and merging are iterative. Very long keys and deeply shared paths do not rely on recursive mutation or compaction. Prefix visitors use pooled traversal storage and do not box per-node enumerators.

Typical costs are:

Operation Cost
Exact lookup/count proportional to encoded key length
Prefix count encoded prefix length
Prefix visit/snapshot prefix length plus visited values/nodes
Add/remove encoded key length plus local child/value maintenance

Built-in key codecs

RadixKeyCodecs supplies reusable, thread-safe codecs for:

  • ordinal and ordinal-ignore-case .NET strings;
  • byte[] and ReadOnlyMemory<byte>;
  • byte, sbyte, and char;
  • signed and unsigned 16-, 32-, and 64-bit integers.
using var ids = new RadixIndex<int, Customer>(RadixKeyCodecs.Int32);

ids.Add(-20, minusTwenty);
ids.Add(42, fortyTwo);

Customer result = ids.FindExact(42).Single();

Integer codecs flip the sign bit where required and write most-significant bytes first. Lexicographic encoded order therefore matches natural integer order. A numeric prefix is a high-byte partition, not a decimal-text prefix:

// Match values sharing the first encoded byte with zero.
int partitionCount = ids.CountByPrefix(0, encodedPrefixLength: 1);

// A zero-byte prefix visits the whole numeric index in encoded key order.
IReadOnlyList<Customer> all = ids.FindByPrefix(0, encodedPrefixLength: 0);

String correctness

RadixKeyCodecs.Utf16Ordinal writes each UTF-16 code unit big-endian. This is intentional:

  • every possible .NET string, including unpaired surrogates, is preserved;
  • byte equality equals StringComparison.Ordinal equality;
  • byte ordering equals .NET ordinal UTF-16 ordering;
  • a string prefix remains an encoded-byte prefix.

UTF-8 is not the default because strict UTF-8 cannot represent unpaired surrogates and its scalar ordering differs from UTF-16 ordinal ordering around the BMP/supplementary boundary.

Utf16OrdinalIgnoreCase uses the runtime-compatible ordinal equivalence model, including Greek sigma and supplementary-plane case pairs. It is not culture-sensitive search.

Custom key types

Implement IRadixKeyCodec<TKey> when a domain key has a stable binary form:

using System.Buffers.Binary;

public sealed class TenantDocumentCodec : IRadixKeyCodec<TenantDocumentKey>
{
    public int GetEncodedLength(TenantDocumentKey key) => 8;

    public int Encode(TenantDocumentKey key, Span<byte> destination)
    {
        BinaryPrimitives.WriteUInt32BigEndian(destination, key.TenantId);
        BinaryPrimitives.WriteUInt32BigEndian(destination[4..], key.DocumentId);
        return 8;
    }
}

public readonly record struct TenantDocumentKey(uint TenantId, uint DocumentId);

A codec must be deterministic and thread-safe, report its exact output length, and write exactly that many bytes. Equal application keys must have identical encodings. Prefix and ordering operations are meaningful only when the codec's byte layout preserves the relationship required by the application.

Multi-value and duplicate behavior

Add always adds one value. The same key/value pair may be added more than once. Remove(key, value) removes one visible match according to the supplied item equality comparer:

using var byEmail = new RadixIndex<string, Person>(
    RadixKeyCodecs.Utf16OrdinalIgnoreCase,
    itemEqualityComparer: PersonIdComparer.Instance);

The optional item comparer globally sorts snapshot results. Without one, terminal values preserve insertion order and prefix traversal follows encoded key order. Equality and ordering comparers must remain stable while values are indexed.

The index stores strong references to values. Index stable IDs instead of full objects when another store should own object lifetime.

Query APIs

Snapshots copy matching references under a read lock and remain valid after later mutation:

IReadOnlyList<TItem> FindExact(TKey key);
IReadOnlyList<TItem> FindByPrefix(TKey prefix);
IReadOnlyList<TItem> FindByPrefix(TKey key, int encodedPrefixLength);

Counts do not materialize results:

int CountExact(TKey key);
int CountByPrefix(TKey prefix);
int CountByPrefix(TKey key, int encodedPrefixLength);

Visitors avoid a result collection on the direct-mode fast path:

int VisitExact(TKey key, Action<TItem> visitor);
int VisitByPrefix(TKey prefix, Action<TItem> visitor);

With no item comparer, direct-mode visitors allocate no managed memory in the measured workloads. On that fast path, the callback runs under the read lock and must not mutate the same index. Buffered mode and a configured item comparer take a visible snapshot first, then invoke the callback after releasing the lock.

Lazy enumeration also holds a read lock until completion or disposal:

IEnumerable<TItem> EnumerateExact(TKey key);
IEnumerable<TItem> EnumerateByPrefix(TKey prefix);

Prefer snapshots for ordinary application code. Use visitors for hot aggregate paths and lazy enumeration only when the caller tightly controls enumerator lifetime.

Mutation modes

Direct mode is the RadixIndex default and applies writes immediately:

using var index = new RadixIndex<string, Person>(
    RadixKeyCodecs.Utf16Ordinal,
    new RadixIndexOptions { MutationMode = RadixIndexMutationMode.Direct });

Buffered mode maintains a main tree, addition overlay, and removal tombstones:

visible = main - removals + additions
using var buffered = new RadixIndex<string, Person>(
    RadixKeyCodecs.Utf16Ordinal,
    new RadixIndexOptions
    {
        MutationMode = RadixIndexMutationMode.Buffered,
        AutomaticCompactionThreshold = 20_000 // zero means explicit only
    });

RadixIndexCompactionResult compacted = buffered.Compact();

Buffered mode is useful when overlay behavior is desirable, but direct mode has lower steady-state complexity and does not retain tombstones. The default automatic threshold is 4,096 pending operations.

AddRange encodes its complete input before taking the write lock. An exception from source enumeration or encoding therefore does not leave a partial batch.

Concurrency and lifetime

One ReaderWriterLockSlim protects each index:

  • counts, snapshots, visitors, and lazy queries take a read lock;
  • add, remove, clear, and compaction take a write lock;
  • returned values are not made immutable or thread-safe;
  • codecs and comparers must be safe for the calling pattern.

Dispose is idempotent, clears strong references, and causes later operations to throw ObjectDisposedException. Do not dispose an index while a lazy enumerator is active.

Current release status

The release gate includes:

  • 96 correctness, differential, stress, Unicode, long-key, codec, adaptive child-shape, mutation, snapshot, disposal, and concurrency tests;
  • full .NET 10 analyzers with warnings treated as errors;
  • tracked public API, XML documentation, deterministic builds, symbols, and package validation;
  • same-process BenchmarkDotNet comparisons and fresh-process retained-memory probes;
  • local package inspection and clean-consumer smoke testing.

The package, assembly, solution, projects, namespace, tests, and benchmark identity are now consistently named RadixForge. RadixForge is available under the MIT License. Source and CI are hosted in the public ToolMaker/RadixForge repository. Every push is validated, while NuGet.org publication is intentionally limited to a published versioned GitHub Release. See RELEASE-CHECKLIST.md.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net10.0

    • No dependencies.

NuGet packages (1)

Showing the top 1 NuGet packages that depend on RadixForge:

Package Downloads
InterfaceDB

Interface-first object repository and embedded object database for .NET 10.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.1.0 129 8/23/2026
1.0.0 83 8/23/2026
0.1.0-preview.1 46 8/22/2026

First RadixForge public preview of the codec-driven concurrent multi-value adaptive radix index.