A bidirectional map (bijection) for MoonBit — a port of Rust’s
bimap crate / Guava BiMap, extended with
insertion-order preservation and index-based access (which neither Rust nor Guava
provides).
A BiMap[L, R] keeps keys and values in one-to-one correspondence: you can look up
left→right and right→left, and every insertion maintains the bijection invariant.
let m = @aurasuisui/bimap.new()
m.insert("alice", "admin") |> ignore
m.insert("bob", "user") |> ignore
// Forward and reverse lookup:
println(m.get_by_left("alice")) // Some("admin")
println(m.get_by_right("user")) // Some("bob")
// Index access (insertion order preserved):
println(m.get_index(0)) // Some(("alice", "admin"))
Why a BiMap? (vs the built-in Map and vs indexmap)
Feature
built-in Map
BiMap
indexmap
key → value
✅
✅
✅
value → key (reverse)
❌
✅
❌
index access get_index(i)
❌
✅
✅
keys unique
✅
✅
✅
values also unique (bijection)
❌
✅
❌
preserves insertion order
impl-defined
✅
✅
Eq/Hash semantics
order-independent
order-independent
order-sensitive
BiMap and indexmap solve orthogonal problems — Bi = bidirectional (one-to-one,
reverse lookup); Index = positional access. They share only the underlying hash table
(as any two maps share arrays). This package is a fresh, dependency-free library, not a
fork or rename of indexmap.
Inverse copy — to_inverse() -> BiMap[R, L] (a copy, not a live view)
Predicate filtering — retain(pred) keeps only the matching pairs in O(n),
preserving their relative insertion order (a port of Rust bimap’s retain)
Sorted bijection — BiBTreeMap: the same contract on a sorted engine
(ascending by left key, range queries, first/last = min/max key), keys need
only Compare; differential-tested against Rust bimap’s BiBTreeMap
Set views + entry helpers — contains_pair(l, r), snapshot arrays left_keys() /
right_values() (insertion order), and the stateless get_or_insert_left /
get_or_insert_right — original extensions (Rust bimap v0.6.3 has none of them;
its same-named right_values() is a lazy unordered iterator, ours is an ordered snapshot)
Standard traits — Debug, Default, Show, Eq/Hash (order-independent),
ToJson, plus QuickCheck Arbitrary
JSON round-trip — from_json / from_json_with (both map types, v0.3.0) decode
{"<left>": <right>, ...} objects with strict bijection validation: two left keys
mapping to the same right raise DuplicateRightValue (the whole parse fails — no silent
eviction); the JSON text key order becomes BiMap insertion order; decode errors pass
through as Decode
Installation
Add the dependency to your project’s moon.mod:
import {
"aurasuisui/bimap@0.3.0",
}
Then import it in the relevant moon.pkg:
import {
"aurasuisui/bimap",
}
The five insertion cases (C0–C4)
Inserting (l, r) into a bijection has five sub-cases — the crux of a correct BiMap:
Case
Condition
insert returns
len change
C0
neither l nor r present
Neither
+1
C1
the exact pair (l, r) already present
Pair(l, r)
0
C2
l was bound to r'≠r; r free
Left(l, r')
0
C3
r was bound to l'≠l; l free
Right(l', r)
0
C4
l→r'andl'→r both exist
Both((l,r'), (l',r))
−1
C4 collapses two pairs into one — insert can reduce the map’s size! This mirrors
Rust bimap‘s Overwritten::Both exactly.
let m = @aurasuisui/bimap.new()
m.insert("a", 1) |> ignore // Neither {a↔1}
m.insert("b", 2) |> ignore // Neither {a↔1, b↔2}
m.insert("a", 4) |> ignore // Left(a, 1) {a↔4, b↔2}
m.insert("c", 2) |> ignore // Right(b, 2) {a↔4, c↔2}
let r = m.insert("a", 2) // Both((a,4),(c,2)) {a↔2} — len 2→1!
Gotchas
insert can shrink the map (C4 collapse). Check the returned Overwritten if you
need to know what was displaced.
Eq and Hash are order-independent. A BiMap is a set of pairs; two maps with
the same pairs in different insertion order are equal and hash the same. This is the
opposite of the author’s indexmap, whose Eq/Hash are order-sensitive. The hash
is hardened (2026-08): it folds the sorted per-pair fingerprint sequence (the pair
set’s canonical form), so map-level collisions reduce to equal fingerprint multisets —
engineering one now requires hash-level control over the keys (e.g. shifting h(l) up by
t while shifting h(r) down by K·t). Remaining boundaries: fingerprint-level
collisions are still possible in principle, and hashing a whole map costs O(n log n)
(the sort). Fine for collections; if you hash very large BiMaps hot, cache the result.
to_inverse() returns a copy, not a live view. Mutating the inverse does not affect
the original (MoonBit’s ownership model favors copies over shared live views; this matches
Rust bimap‘s method-based access rather than Guava’s live inverse()).
ToJson keys use l.to_string() (L : Show), so String keys serialize verbatim.
Don’t mutate the map while an iterator is active — iterators are fail-fast (they
snapshot a mutation counter and abort if the map changes mid-iteration).
A rebind (C2) keeps the left key’s insertion position — rebinding l to a new right
value does not move l to the end of the order. This is an intentional, order-preserving
extension over Rust’s remove-then-reinsert behavior (see CHANGELOG).
BiMap is not thread-safe. It is mutable and its iterators are fail-fast; concurrent
reads/writes from multiple threads are undefined behavior. Use one BiMap per thread, or
guard shared access with external synchronization.
Removal shifts the insertion order — O(n) worst case.remove_by_left /
remove_by_right keep the order array dense by shifting later entries, so a single removal
costs O(len) in the worst case, and removing n pairs in insertion (head-first) order is
O(n²) overall (bench-measured, see Performance). For bulk clear-outs, drain in reverse
insertion order (each shift degenerates to O(1)) or rebuild (from_array / copy).
from_json validates strictly; parse_key must be total. A JSON object with two
left keys decoding to the same right raises DuplicateRightValue instead of silently
evicting a pair (the opposite of Rust bimap’s serde, which overwrites). The parse_key
you pass to from_json_with has no raise channel — absorb parse failures inside it
(e.g. catch @string.parse_int and fall back to a default). And R = Json is
type-level impossible: Json has no Hash/Compare, which the right side of both
map types requires.
get_or_insert_left(l, r) -> R, get_or_insert_right(r, l) -> L
Bulk
retain(pred)
Convert
to_inverse() -> BiMap[R, L]
Traits
Debug, Default, Show, Hash, Eq, ToJson, Arbitrary
JSON round-trip (from_json)
ToJson has a strict inverse (v0.3.0): from_json / from_json_with decode a JSON
object { "<left>": <right-json>, ... } back into a bijection, with strict bijection
validation — a right-value conflict raises BiMapDecodeError::DuplicateRightValue
(payload: right value + both conflicting left keys), value decode errors pass through as
Decode and take priority, and duplicate keys are last-wins. For BiMap use the free
function; BiBTreeMap is reached by method call (MoonBit free functions cannot overload
by return type). Both raise BiMapDecodeError — catch it, or let it propagate:
let json = @json.parse("{\"bob\":2,\"alice\":1}") // text -> Json
// BiMap: free function; insertion order = JSON text key order
let m : @aurasuisui/bimap.BiMap[String, Int] = @aurasuisui/bimap.from_json(json)
// BiBTreeMap: method call; sorted by left key (input key order irrelevant)
let s : @aurasuisui/bimap.BiBTreeMap[String, Int] =
@aurasuisui/bimap.BiBTreeMap::from_json(json)
// Int-keyed text: from_json_with + a TOTAL parse_key (P1-2: no raise channel —
// absorb parse failures inside, e.g. fall back to a default):
let ints = @json.parse("{\"10\":10,\"20\":20}")
let n : @aurasuisui/bimap.BiMap[Int, Int] = @aurasuisui/bimap.from_json_with(
ints,
fn(k) { @string.parse_int(k) catch { _ => 0 } },
)
The round-trip is lossless: from_json(m.to_json()) == m (set equality), and for BiMap
the iteration order survives as the text key order. parse_key must be total.R = Json is type-level impossible (Json lacks Hash/Compare). See
docs/SPEC.md §12 and the runnable cmd/json_roundtrip example.
BiBTreeMap — the sorted variant
BiBTreeMap[L, R] is the same bijection on a sorted engine: two core SortedMap
tables (ordered by left / by right). Sorted order replaces insertion order — no index
access, first()/last() return the smallest/largest left key, and range(lo, hi)
slices by left key (inclusive on both ends; the reverse-side range is deferred — see
CHANGELOG). The whole C0–C4 insertion contract, retain, the M2 views/entry helpers,
Eq/Hash set semantics, and fail-fast iterators carry over unchanged; keys only need
Compare (no Hash required), and the snapshot/iteration/copy methods are all
zero-bound. Differential-tested against the real Rust bimapBiBTreeMap v0.6.3
(golden + 6000-op stream + exact sorted terminal state).
let m = @aurasuisui/bimap.BiBTreeMap::from_array([("b", 2), ("a", 1), ("c", 3)])
println(m.into_array()) // [("a", 1), ("b", 2), ("c", 3)] — ascending by left
println(m.first()) // Some(("a", 1)) — smallest left key
println(m.range("a", "b").to_array()) // [("a", 1), ("b", 2)] — [lo, hi] inclusive
get_or_insert_left(l, r) -> R, get_or_insert_right(r, l) -> L
Bulk
retain(pred)
Iterate
iter(), into_array()
Convert
to_inverse() -> BiBTreeMap[R, L]
Traits
Debug, Default, Show, Hash, Eq, ToJson, Arbitrary
Design
Two inverse Robin Hood hash tables (forward: L→R, backward: R→L) keep the bijection.
One shared order array + positions map tracks left-key insertion order, enabling
index access without a second order structure on the backward table.
All mutations funnel through private put_pair / remove_by_left / remove_by_right
helpers that maintain the invariants: ∀(l,r)∈forward ⟺ backward[r]==l, and five
consistent counters.
The Robin Hood engine is adapted from the author’s aurasuisui/indexmap (see below).
cmd/username_email — username ↔ email bidirectional lookup, iteration, and a rebind
cmd/country_code — country name ↔ ISO code ("China" ↔ "CN"), reverse lookup, index
access, and non-overwriting insert
cmd/json_roundtrip — from_json/from_json_with on both map types, strict-conflict
error handling, and the lossless text round-trip (runs against the published @0.3.0
after release)
Note: the cmd/* example packages are standalone modules (they import the publishedaurasuisui/bimap and are not part of the root package — the repo ships no workspace
manifest). To run one, make the package resolvable (e.g. after moon publish) and run
moon run cmd/<name>.
Performance
Measured with bench/ (official @bench framework,
native backend, --release, benchmarking the published package) on an
AMD Ryzen 7 7840H, Windows 11, moonc v0.10.8 (2026-08-19). Median of 5
samples, Int ↔ Int maps at n = 100 000, per-operation cost:
operation
BiMap
built-in Map
insert (fresh pair)
≈ 2 980 ns
≈ 410 ns
get_by_left (hit)
≈ 260 ns
≈ 130 ns (get)
get_by_right (hit)
≈ 330 ns
—
insert_no_overwrite (conflict)
≈ 300 ns
—
remove_by_left (tail-first drain)¹
≈ 1 510 ns
≈ 396 ns²
into_array (full traversal)
≈ 495 ns/pair
—
¹ Derived: insert+remove_all-reverse (≈ 4 490 ns/op) minus
insert-fresh; draining in tail order makes each order shift O(1).
² Built-in Map figure is insert+remove combined, not directly split.
Read the numbers as the constant-factor price of bidirectionality +
insertion order + index access: lookups run ≈2× the built-in map, inserts
≈7× (every write touches two hash tables, the order array, and the
positions map). Reverse lookup stays within 25% of forward lookup.
Worst case — bulk removal in insertion order:order_remove is an O(n)
shift, so draining n pairs head-first is O(n²) overall (measured ≈ 133 µs/op
at n = 10 000, ≈ 90× the tail-first drain). Drain in reverse insertion
order, or rebuild — see Gotcha #9.
Reproduce with moon run --release bench/main.mbt (see bench/README.md;
single-machine numbers, indicative of constant factors, not absolute speed).
Development
moon check # type check
moon test # run all 369 tests
moon fmt # format
moon build # build
The five-step CI pipeline runs: moon fmt --check → moon check →
moon info && git diff --exit-code → moon test → moon build.
See CONTRIBUTING.md for the architecture deep-dive and test conventions.
Known Issues
Fail-fast abort is not in-process testable. Mutating a map mid-iteration triggers
abort, which the MoonBit test framework cannot catch as a passing assertion (a panicking
test is reported as failed, not as “expected panic”). The version-snapshot + abort logic in
src/bimap_iter.mbt is verified by inspection and by a manual reproduction (documented
there); all other iterator behavior is fully tested.
Acknowledgements & Licensing
The Robin Hood hash-table engine is adapted from the author’s
aurasuisui/indexmap (Apache-2.0).
The BiMap semantics (insert/insert_no_overwrite, Overwritten, bidirectional
lookup) are ported from the Rust bimap crate
(MIT / Apache-2.0), with conceptual reference to Guava BiMap (Apache-2.0).
Order preservation and index access are original additions.
moonbit-bimap
A bidirectional map (bijection) for MoonBit — a port of Rust’s
bimapcrate / GuavaBiMap, extended with insertion-order preservation and index-based access (which neither Rust nor Guava provides).A
BiMap[L, R]keeps keys and values in one-to-one correspondence: you can look up left→right and right→left, and every insertion maintains the bijection invariant.Why a BiMap? (vs the built-in
Mapand vsindexmap)Mapindexmapget_index(i)Eq/HashsemanticsBiMapandindexmapsolve orthogonal problems — Bi = bidirectional (one-to-one, reverse lookup); Index = positional access. They share only the underlying hash table (as any two maps share arrays). This package is a fresh, dependency-free library, not a fork or rename ofindexmap.Features
get_by_left/get_by_right,contains_left/contains_rightinsertreturns anOverwrittenenum describing what was displaced (including the classic C4 collapse, see below)insert_no_overwritereturnsResult[Unit, (L, R)]iter()yields pairs in the order left keys were insertedget_index(i),get_index_of_left,get_index_of_right,first(),last()to_inverse() -> BiMap[R, L](a copy, not a live view)retain(pred)keeps only the matching pairs in O(n), preserving their relative insertion order (a port of Rust bimap’sretain)BiBTreeMap: the same contract on a sorted engine (ascending by left key,rangequeries,first/last= min/max key), keys need onlyCompare; differential-tested against Rust bimap’sBiBTreeMapcontains_pair(l, r), snapshot arraysleft_keys()/right_values()(insertion order), and the statelessget_or_insert_left/get_or_insert_right— original extensions (Rust bimap v0.6.3 has none of them; its same-namedright_values()is a lazy unordered iterator, ours is an ordered snapshot)Debug,Default,Show,Eq/Hash(order-independent),ToJson, plus QuickCheckArbitraryfrom_json/from_json_with(both map types, v0.3.0) decode{"<left>": <right>, ...}objects with strict bijection validation: two left keys mapping to the same right raiseDuplicateRightValue(the whole parse fails — no silent eviction); the JSON text key order becomesBiMapinsertion order; decode errors pass through asDecodeInstallation
Add the dependency to your project’s
moon.mod:Then import it in the relevant
moon.pkg:The five insertion cases (C0–C4)
Inserting
(l, r)into a bijection has five sub-cases — the crux of a correct BiMap:insertreturnslenchangelnorrpresentNeither(l, r)already presentPair(l, r)lwas bound tor'≠r;rfreeLeft(l, r')rwas bound tol'≠l;lfreeRight(l', r)l→r'andl'→rboth existBoth((l,r'), (l',r))Gotchas
insertcan shrink the map (C4 collapse). Check the returnedOverwrittenif you need to know what was displaced.EqandHashare order-independent. ABiMapis a set of pairs; two maps with the same pairs in different insertion order are equal and hash the same. This is the opposite of the author’sindexmap, whoseEq/Hashare order-sensitive. The hash is hardened (2026-08): it folds the sorted per-pair fingerprint sequence (the pair set’s canonical form), so map-level collisions reduce to equal fingerprint multisets — engineering one now requires hash-level control over the keys (e.g. shiftingh(l)up bytwhile shiftingh(r)down byK·t). Remaining boundaries: fingerprint-level collisions are still possible in principle, and hashing a whole map costs O(n log n) (the sort). Fine for collections; if you hash very large BiMaps hot, cache the result.to_inverse()returns a copy, not a live view. Mutating the inverse does not affect the original (MoonBit’s ownership model favors copies over shared live views; this matches Rustbimap‘s method-based access rather than Guava’s liveinverse()).ToJsonkeys usel.to_string()(L : Show), soStringkeys serialize verbatim.from_arrayresolves duplicate pairs by “last wins” (viainsert), matching Rust’sFromIterator.lto a new right value does not movelto the end of the order. This is an intentional, order-preserving extension over Rust’s remove-then-reinsert behavior (see CHANGELOG).BiMapis not thread-safe. It is mutable and its iterators are fail-fast; concurrent reads/writes from multiple threads are undefined behavior. Use oneBiMapper thread, or guard shared access with external synchronization.remove_by_left/remove_by_rightkeep the order array dense by shifting later entries, so a single removal costs O(len) in the worst case, and removing n pairs in insertion (head-first) order is O(n²) overall (bench-measured, see Performance). For bulk clear-outs, drain in reverse insertion order (each shift degenerates to O(1)) or rebuild (from_array/copy).from_jsonvalidates strictly;parse_keymust be total. A JSON object with two left keys decoding to the same right raisesDuplicateRightValueinstead of silently evicting a pair (the opposite of Rust bimap’s serde, which overwrites). Theparse_keyyou pass tofrom_json_withhas no raise channel — absorb parse failures inside it (e.g. catch@string.parse_intand fall back to a default). AndR = Jsonis type-level impossible:Jsonhas noHash/Compare, which the right side of both map types requires.API Overview
new(),with_capacity(n),from_array(pairs),default()(Defaulttrait 实现),copy(),from_json(json),from_json_with(json, parse_key)len(),is_empty(),capacity()insert(l, r) -> Overwritten,insert_no_overwrite(l, r) -> Result[Unit,(L,R)]get_by_left(l),contains_left(l),remove_by_left(l) -> R?get_by_right(r),contains_right(r),remove_by_right(r) -> L?get_index(i),get_index_of_left(l),get_index_of_right(r),first(),last()iter(),lefts(),rights(),into_array()contains_pair(l, r),left_keys(),right_values()get_or_insert_left(l, r) -> R,get_or_insert_right(r, l) -> Lretain(pred)to_inverse() -> BiMap[R, L]Debug,Default,Show,Hash,Eq,ToJson,ArbitraryJSON round-trip (
from_json)ToJsonhas a strict inverse (v0.3.0):from_json/from_json_withdecode a JSON object{ "<left>": <right-json>, ... }back into a bijection, with strict bijection validation — a right-value conflict raisesBiMapDecodeError::DuplicateRightValue(payload: right value + both conflicting left keys), value decode errors pass through asDecodeand take priority, and duplicate keys are last-wins. ForBiMapuse the free function;BiBTreeMapis reached by method call (MoonBit free functions cannot overload by return type). Both raiseBiMapDecodeError— catch it, or let it propagate:The round-trip is lossless:
from_json(m.to_json()) == m(set equality), and forBiMapthe iteration order survives as the text key order.parse_keymust be total.R = Jsonis type-level impossible (JsonlacksHash/Compare). Seedocs/SPEC.md§12 and the runnablecmd/json_roundtripexample.BiBTreeMap — the sorted variant
BiBTreeMap[L, R]is the same bijection on a sorted engine: two coreSortedMaptables (ordered by left / by right). Sorted order replaces insertion order — no index access,first()/last()return the smallest/largest left key, andrange(lo, hi)slices by left key (inclusive on both ends; the reverse-side range is deferred — see CHANGELOG). The whole C0–C4 insertion contract,retain, the M2 views/entry helpers,Eq/Hashset semantics, and fail-fast iterators carry over unchanged; keys only needCompare(noHashrequired), and the snapshot/iteration/copy methods are all zero-bound. Differential-tested against the real RustbimapBiBTreeMapv0.6.3 (golden + 6000-op stream + exact sorted terminal state).new(),from_array(pairs),default()(Defaulttrait 实现),copy(),from_json(json),from_json_with(json, parse_key)len(),is_empty(),first(),last(),range(lo, hi) -> Iterinsert(l, r) -> Overwritten,insert_no_overwrite(l, r) -> Result[Unit,(L,R)]get_by_left(l),contains_left(l),remove_by_left(l) -> R?get_by_right(r),contains_right(r),remove_by_right(r) -> L?contains_pair(l, r),left_keys(),right_values()get_or_insert_left(l, r) -> R,get_or_insert_right(r, l) -> Lretain(pred)iter(),into_array()to_inverse() -> BiBTreeMap[R, L]Debug,Default,Show,Hash,Eq,ToJson,ArbitraryDesign
forward: L→R,backward: R→L) keep the bijection.orderarray +positionsmap tracks left-key insertion order, enabling index access without a second order structure on the backward table.put_pair/remove_by_left/remove_by_righthelpers that maintain the invariants:∀(l,r)∈forward ⟺ backward[r]==l, and five consistent counters.aurasuisui/indexmap(see below).Examples
Runnable example packages live in
cmd/:cmd/username_email— username ↔ email bidirectional lookup, iteration, and a rebindcmd/country_code— country name ↔ ISO code ("China" ↔ "CN"), reverse lookup, index access, and non-overwriting insertcmd/json_roundtrip—from_json/from_json_withon both map types, strict-conflict error handling, and the lossless text round-trip (runs against the published@0.3.0after release)Performance
Measured with
bench/(official@benchframework, native backend,--release, benchmarking the published package) on an AMD Ryzen 7 7840H, Windows 11, moonc v0.10.8 (2026-08-19). Median of 5 samples,Int ↔ Intmaps at n = 100 000, per-operation cost:Mapinsert(fresh pair)get_by_left(hit)get)get_by_right(hit)insert_no_overwrite(conflict)remove_by_left(tail-first drain)¹into_array(full traversal)¹ Derived:
insert+remove_all-reverse(≈ 4 490 ns/op) minusinsert-fresh; draining in tail order makes eachordershift O(1). ² Built-inMapfigure is insert+remove combined, not directly split.Read the numbers as the constant-factor price of bidirectionality + insertion order + index access: lookups run ≈2× the built-in map, inserts ≈7× (every write touches two hash tables, the
orderarray, and thepositionsmap). Reverse lookup stays within 25% of forward lookup. Worst case — bulk removal in insertion order:order_removeis an O(n) shift, so draining n pairs head-first is O(n²) overall (measured ≈ 133 µs/op at n = 10 000, ≈ 90× the tail-first drain). Drain in reverse insertion order, or rebuild — see Gotcha #9.Reproduce with
moon run --release bench/main.mbt(seebench/README.md; single-machine numbers, indicative of constant factors, not absolute speed).Development
The five-step CI pipeline runs:
moon fmt --check→moon check→moon info && git diff --exit-code→moon test→moon build.See CONTRIBUTING.md for the architecture deep-dive and test conventions.
Known Issues
abortis not in-process testable. Mutating a map mid-iteration triggersabort, which the MoonBit test framework cannot catch as a passing assertion (a panicking test is reported as failed, not as “expected panic”). The version-snapshot + abort logic insrc/bimap_iter.mbtis verified by inspection and by a manual reproduction (documented there); all other iterator behavior is fully tested.Acknowledgements & Licensing
aurasuisui/indexmap(Apache-2.0).insert/insert_no_overwrite,Overwritten, bidirectional lookup) are ported from the Rustbimapcrate (MIT / Apache-2.0), with conceptual reference to GuavaBiMap(Apache-2.0). Order preservation and index access are original additions.License
Apache 2.0 — see LICENSE.
Built for the 2026 MoonBit Open Source Ecosystem Hackathon (August).