ADR 0090: Canonical Array Representation for O(log n) at:put:
Status
Implemented (2026-06-25) — map-backed representation (BT-2680). Accepted 2026-06-02.
Context
Problem statement
Beamtalk's Array is a value type backed by BEAM's array module, wrapped in a
tagged map:
#{'$beamtalk_class' => 'Array', 'data' => ErlangArray}
array:get/2 and array:set/3 are O(log n), giving Array its reason to exist
over a plain list. But array:set/3 is not canonical: it leaves a stale
copy-on-write cache node inside the array tuple, so the result is not =:= to
an array:from_list/1-built array with identical elements:
from_list([1,99,3]) -> {array,3,0,false,undefined,{1,99,3,...},0,{1,99,3,...},0}
set(1,99,from_list([1,2,3])) -> {array,3,0,false,undefined,{1,99,3,...},0,{1,2,3,...},0}
^^ stale cache
Both render as #[1, 99, 3], but they compare unequal and hash differently.
Current state
BT-2362 (PR #2407) fixed the resulting bug — #[1,2,3] at: 2 put: 99 comparing
unequal to the literal #[1, 99, 3] — by re-canonicalising every at:put:
result through from_list(array:to_list(...)):
%% beamtalk_array:at_put/3 (current)
Updated = array:set(Index - 1, Value, Arr),
from_list(array:to_list(Updated)).
This is correct but O(n) per update (vs array:set/3's O(log n)), making
repeated at:put: in a loop O(n²). Measured cost: 5000 at:put: calls on a
200k-element array take 96.8 s, versus 583 µs for the raw array:set
path — roughly a 166,000× slowdown. The current at_put/3 doc comment
already flags this as a deliberate, temporary trade-off pending this ADR.
Every other Array operation (collect, select, slice_from, withAll:,
literals) already builds via array:from_list/array:map, which produce the
canonical representation. at:put: was the only array:set caller and thus
the only outlier — which is why this is purely an internal-representation
question, not a question of Array's public protocol.
Constraints on the solution space
These constraints were discovered while scoping BT-2377 and shape the entire decision:
-
Equality is inlined, not dispatched.
=and=:=both compile to inlineerlang:'=:='(L, R)at every call site (crates/beamtalk-core/src/codegen/core_erlang/operators.rs:61), per ADR 0002 (strict structural equality, no coercion). There is no runtime equality dispatch for value types — Beamtalk never calls anArray>>=method. Any approach that wants "two structurally-equal arrays compare equal" must either (a) make the underlying terms genuinely=:=, or (b) amend ADR 0002 to introduce runtime equality dispatch for value types. -
Dictionary keys use BEAM
=:=.Dictionaryis backed by native Erlang maps (beamtalk_map.erl,maps:find/2). Map key identity is BEAM=:=, hard-wired in the VM. There is no interception hook for custom key identity. -
Set membership uses term order.
Setis backed byordsets(beamtalk_set.erl), whose membership and dedup use Erlang term comparison (==/<). Again, no custom-identity hook. -
Array operations are fully encapsulated. All Array message sends route through
beamtalk_array:*helper functions (crates/beamtalk-core/src/codegen/core_erlang/primitives/array.rs), and array literals route throughbeamtalk_array:from_list/1(expressions.rs:411). No codegen site inspects the internal'data'shape; class dispatch keys only on the'$beamtalk_class' => 'Array'tag. The internal representation can therefore change without touching codegen or the public protocol.
Constraints 1–3 together mean a "structural =/hash for Array only" approach
cannot make two cache-different-but-element-equal arrays usable as equivalent
dict/set keys without also amending ADR 0002 and adding key-identity hooks to
maps and ordsets — which the BEAM does not offer. This is the crux: correctness
for arrays-as-keys is essentially free if the underlying term is canonical, and
expensive-to-impossible otherwise.
Decision
Replace Array's internal representation with a structure whose updates are
naturally =:=-canonical — i.e. any array holding a given element sequence is
=:= to any other array holding the same sequence, regardless of edit history —
while preserving O(log n) random access and update.
This ADR commits to the property (canonical term ⇒ =:=/phash2-equal by
contents), not to one concrete structure — the structure is settled by benchmark
in implementation (see Implementation, Phase 1). Two shapes are viable, and both
share the invariant that the in-memory term is a pure deterministic function of
(length, elements) with no mutable cache slot (the exact thing that makes
BEAM array non-canonical):
- Map-backed (
#{0 => V0, 1 => V1, …}) — the low-risk default. Canonicality is a BEAM VM guarantee: two maps are=:=iff they hold the same associations, regardless of insertion order or HAMT layout. Zero dependencies, pure Erlang, O(log n) update; costs extra memory (stores the index keys) and slower full iteration. - A bespoke strict radix-balanced persistent vector — the performance
upgrade. Stores only values (lowest memory), best iteration. Must be the
strict (non-relaxed) variant so each
(length, elements)has exactly one term shape; a relaxed RRB-vector is not canonical (it relaxes node fill for fast concat) and is therefore disqualified. No off-the-shelf BEAM library provides a canonical vector, so this path means writing one in-tree — taken only if the benchmark justifies it over the map.
The public protocol, the tagged-map wrapper, and the '$beamtalk_class' => 'Array' tag are unchanged. Only beamtalk_array.erl's internals and its
'data' payload change.
What the user sees
Nothing changes in behaviour — only performance and correctness-at-scale:
a := #[1, 2, 3]
b := a at: 2 put: 99 // O(log n), not O(n)
b // => #[1, 99, 3]
b =:= #[1, 99, 3] // => true (canonical: same contents ⇒ =:=)
// Arrays as dictionary keys — works because terms are =:=-canonical
d := Dictionary new
d at: (#[1, 2] at: 2 put: 9) put: "hit"
d at: #[1, 9] // => "hit" (cache history irrelevant)
// Repeated updates in a loop — O(n log n) total, not O(n²)
big := Array withAll: (1 to: 200000)
result := (1 to: 5000) inject: big into: [:acc :i | acc at: i put: 0]
result size // => 200000 (sub-second, not ~97s)
Error behaviour is unchanged
Out-of-bounds and type errors keep raising the same #beamtalk_error{} values
with the same selectors and hints — those paths live in at_put/3 above the
representation layer:
#[1, 2, 3] at: 9 put: 0 // index_out_of_bounds, selector at:put:
#[1, 2, 3] at: "x" put: 0 // type_error, selector at:put:
Acceptance criteria for the chosen representation
Whatever concrete structure is selected during implementation must satisfy:
- Canonical: for all element sequences
Es, every construction path (from_list, repeatedat_put,collect,select,slice_from) yieldingEsproduces terms that are mutually=:=anderlang:phash2-equal. - O(log n)
at/at_put(the whole point — must beat the list fallback and the current O(n) canonicalisation). - Equality/hash cost acceptable for dict/set keys:
=:=short-circuits on first difference;phash2is O(n) but no worse than today. - Memory within a small constant factor of BEAM
arrayfor typical sizes. - No mutable/COW cache in the term (the BT-2362 footgun must not recur).
Prior Art
| Platform | Array/vector representation | Canonical under update? | Notes |
|---|---|---|---|
BEAM array | Tuple tree with a COW cache slot | No | The cache slot is exactly the source of BT-2362. O(log n) ops, but non-canonical. |
| Clojure | Persistent vector (32-way trie) | Yes (via .equals) | The canonical persistent-vector shape; O(log₃₂ n) ≈ effectively-constant updates. Equality is by .equals/hashCode override, not JVM reference identity — see caveat below. |
Scala Vector / RRB | Relaxed Radix Balanced trees | Yes (via equals) | Adds O(log n) concat/slice. Same as Clojure: value-equality via an equals method, not structural object identity. |
Erlang gb_trees/maps | Balanced trees / HAMT | Yes (maps are canonical by key) | A map keyed by index is canonical and O(log n), at higher memory and slower iteration. |
| Erlang lists | Cons cells | Yes | Canonical and simple but O(n) random access — the status quo we improved away from in BT-822. |
Pharo/Squeak Array | Mutable fixed-size object | N/A | Smalltalk arrays are mutable in place; identity ≠ equality. Beamtalk arrays are immutable value objects, so this model doesn't transfer. |
What we adopt: the persistent-vector / nested-tuple-trie shape (O(log n),
no cache slot). What we reject: BEAM array's cache optimisation (non-canonical)
and Smalltalk's mutable arrays (wrong semantics for a value type).
Caveat — we adopt the shape, not the equality mechanism. Clojure and Scala
get vector equality from an equals/hashCode override, because the JVM
compares objects by reference identity otherwise — that is the JVM analog of this
ADR's rejected Option 2 (dispatched equality). Beamtalk instead relies on
Erlang's =:= being purely structural over the term, so it gets equality "for
free" only if the term itself is canonical — i.e. there is exactly one term
shape per (length, elements), independent of construction history. That
canonicality is an additional discipline the implementation must enforce (no
Clojure-style transient/tail variation that yields two different node layouts for
the same logical vector); it is not automatic just because the structure is a
trie. Acceptance criterion 1 below makes this requirement explicit, and it is the
load-bearing invariant of the whole decision.
User Impact
- Newcomer: No visible change except that arrays "just work" as dictionary keys and large-array loops don't mysteriously hang. The O(n²) cliff they could previously fall off (5000 updates → 97 s) disappears.
- Smalltalk developer: Arrays remain immutable value objects with structural
=. This is more consistent than Pharo (where array=is element-wise but identity differs); the change strengthens "same contents ⇒ equal." - Erlang/BEAM developer: The
'data'payload is no longer an Erlangarrayrecord. Anyone reaching into the raw term via FFI would need to use thebeamtalk_arrayAPI instead — but that was always the supported boundary.phash2and=:=behave predictably (canonical terms), which is what BEAM developers expect. - Operator: No hot-code-reload impact (internal helper module). Removes a
latent production hazard: a tight
at:put:loop over a large array currently degrades to O(n²) and can stall a process for tens of seconds. - Tooling developer: No AST/LSP impact; the representation is below the language surface.
Steelman Analysis
Option 1 — Change the representation (Recommended)
- 🧑💻 Newcomer: "Arrays behave the way I'd assume — equal contents are equal, and a loop of updates doesn't freeze. I never have to know why."
- 🎩 Smalltalk purist: "Immutable value object with structural equality — this is the clean version of Smalltalk arrays, with history-independent identity."
- ⚙️ BEAM veteran: "A cache-free nested-tuple trie is genuinely
=:=andphash2-stable. No VM-level trickery, no key-identity hooks — it just works with maps and ordsets." - 🏭 Operator: "Eliminates an O(n²) stall in production. One contained module change, no protocol churn, no migration."
- 🎨 Language designer: "Keeps ADR 0002's strict-
=:=contract intact and makes dict/set keys correct for free. The invariant 'contents determine the term' is the elegant one."
Option 2 — Structural =/hash for Array + amend ADR 0002
- 🧑💻 Newcomer: "
a =:= bwould still work for arrays" — but only if they never put arrays in a dict/set, where it silently breaks. - 🎩 Smalltalk purist: "Runtime equality dispatch is more Smalltalk-like —
=is a real message you can override." (Weaker than it sounds:=is not a capability Beamtalk is declining to wire up — it is one of an entire family of binary operators (+ - * / % ** == =:= /= =/= < > <= >=) that all codegen to inlineerlang:'<op>'(L, R)with no dispatch and no override hook (operators.rs:54–101). Making=dispatched would make it the lone overridable binary operator in a language that inlines every other one for BEAM efficiency — an inconsistency, not a return to Smalltalk faithfulness.) - ⚙️ BEAM veteran: "I'd rather not pay a dispatch call on every
=; and this still can't fix map keys, since BEAM=:=is hard-wired." (Weak for this cohort.) - 🏭 Operator: "Smaller diff to the array module" — but
at:put:stays O(log n) only for equality, and the dict/set-key gap is a lurking correctness bug. - 🎨 Language designer: "Opening value-type equality to dispatch is a big semantic lever for one collection's perf bug — and it descopes the dict/set case rather than solving it."
Option 3 — Status quo (O(n) canonicalisation)
- 🧑💻 Newcomer: "Correct in every case I can observe." (True — until the perf cliff bites.)
- 🎩 Smalltalk purist: "Behaviourally indistinguishable; arrays are equal and usable as keys."
- ⚙️ BEAM veteran: "Dead simple, no new data structure to audit."
- 🏭 Operator: "This is the one that stalls for 97 s on a big update loop. Hard no at scale."
- 🎨 Language designer: "Correct but quadratic — fine as a stopgap, not as the intended design."
Tension points
- Only the Smalltalk purist has any pull toward Option 2 (equality as an
overridable message) — and even that pull is weak, because overridable
=would be a lone exception in a language that inlines its entire binary-operator family to Erlang BIFs with no dispatch (see the Option 2 steelman). Every other cohort favours Option 1, and Option 2 cannot fix dict/set keys regardless — so the purist's argument is not just "about language philosophy rather than solving the problem," it argues for a one-off inconsistency the language has deliberately avoided everywhere else. - Newcomer and operator disagree on Option 3: it looks correct in casual use but is the production hazard. That gap (correct-looking but quadratic) is the strongest reason not to leave it as the long-term answer.
Alternatives Considered
Option 2 — Structural =/hash for Array, amend ADR 0002
Keep BEAM array and stop canonicalising in at:put: (restore O(log n)).
Instead, introduce runtime equality dispatch for value types so that Array>>=
compares element-wise, and define Array>>hash element-wise.
Rejected as the primary choice because:
- It requires amending ADR 0002, replacing inlined
erlang:'=:='with dispatched equality for value types — a large semantic and performance change to the whole language to fix one collection's update cost. - It does not fix dict/set keys. Map key identity is BEAM
=:=and ordset membership is term order; neither offers a custom-identity hook. Two cache-different arrays would still be distinct map keys. So this option descopes arrays-as-keys rather than solving them — leaving a latent correctness bug. - Net: more invasive and less complete than Option 1.
Option 3 — Status quo (O(n) canonicalisation in at:put:)
Keep PR #2407's from_list(array:to_list(...)). Correct in all cases, zero new
code. Rejected as the long-term answer because repeated at:put: is O(n²)
(measured ~166,000× slowdown at 200k×5000), which is a real production hazard for
any array-mutation loop. Acceptable only as the interim state until Option 1
lands.
Option 4 — Patch the BEAM array cache slot in place (rejected)
The cheapest-looking fix: keep BEAM array, and after array:set/3 normalise the
stale copy-on-write cache node back to canonical form with setelement/3,
avoiding the O(n) from_list round-trip. Investigated and rejected: for a
single-leaf array the divergence is one tuple slot and the patch works, but for
multi-leaf arrays the internal subtree caches diverge at multiple nesting
levels, and the setelement patch breaks (verified during BT-2377 scoping).
There is no robust, cheap in-place canonicalisation of a BEAM array — which is
precisely why the representation has to change.
Sub-alternatives within Option 1 (to settle at implementation time)
- Map-backed (index → value): canonical and O(log n), simplest to implement,
but higher memory and slower full-iteration (
do:/collect:). Good fallback if the trie proves too costly to build well. Canonicality here is a BEAM VM guarantee, not a discipline we maintain: two Erlang maps are=:=iff they hold the same key→value associations, independent of insertion order or internal HAMT layout, anderlang:phash2hashes maps by content the same way. Sofrom_list([1,99,3])and(from_list([1,2,3]))#{1 => 99}are genuinely=:=with zero extra work — which is what makes this the low-risk fallback. gb_trees(rejected): technically canonical only under a fragile discipline — always build viafrom_orddict(deterministic balanced shape over keys0..n-1) and only everupdate/3(which replaces a value in place without rebalancing; safe because bounds-checkedat:put:never inserts a new key). But general balanced trees are otherwise insertion-order-dependent, it stores the integer keys and two child pointers per element (heaviest memory of the candidates), and it offers no advantage over a map (which gets the same canonicality from the VM with no invariant to maintain). Dominated by the map-backed option for simplicity and by the persistent vector for performance.- Chunked tuples: flat tuple split into fixed-size chunks; O(√n) or O(log n) depending on nesting. Simpler than a full trie but with worse asymptotics.
- Strict radix-balanced persistent vector (preferred if the benchmark
justifies it): best asymptotics, stores only values (no keys → lowest
memory), highest implementation effort. Must be the strict, non-relaxed
variant (Clojure's original
PersistentVectordiscipline, with a normalised or absent tail) so that a given element sequence has exactly one term shape. Note: a relaxed RRB-vector is explicitly NOT canonical — RRB relaxes node fill precisely to makeconcat/splitO(log n), which permits multiple shapes per content and breaks=:=. The implementation issue should benchmark at least the map-backed and strict-vector options against the acceptance criteria before committing.
Consequences
Positive
- O(log n)
at:put:; eliminates the O(n²) loop hazard (≈166,000× faster on the measured workload). - Arrays-as-dict/set-keys are correct for free — no map/ordset hooks needed.
- ADR 0002's strict-
=:=, no-coercion, inlined-equality contract stays intact. - Change is confined to
beamtalk_array.erland the array-literal construction path; no codegen, protocol, or migration impact (constraint 4). - Removes the BT-2362 footgun class entirely (no mutable cache slot to leak).
Negative
- Some implementation cost vs leaning on stdlib
array. Bounded for the map-backed default (reuses native maps, no new data structure); higher for the strict-vector path (a structure to implement, test, and maintain) — which is why that path is taken only if the benchmark justifies it. - Memory overhead vs BEAM
array: the map-backed default stores the integer index keys (heavier); the strict vector stores only values. Either way the change must be bounded by the acceptance criteria. phash2of a large array key remains O(n) (unchanged from today, but now a documented, intended cost rather than incidental).
Neutral
- Public Array protocol, the tagged-map wrapper, and the
'$beamtalk_class'tag are unchanged. - FFI consumers reaching into the raw
'data'term (unsupported) would break; the supported boundary (beamtalk_arrayAPI) is unaffected.
Implementation
Confined to the Runtime context:
-
Choose the representation. Prototype and benchmark the map-backed and strict-persistent-vector candidates against the five acceptance criteria (canonicality, O(log n) ops, equality/hash cost, memory, no cache slot). Document the choice and benchmark numbers.
No off-the-shelf BEAM library satisfies the canonicality criterion, so do not plan to depend on one: the available persistent-vector libraries are all relaxed RRB-vectors optimised for concat (
iv— pure-BEAM via Gleam;arrays_rrb_vector— an Elixir NIF over Rust'sim::Vector), and relaxed RRB is non-canonical by design (multiple shapes per content → not=:=). The NIF option is doubly unusable: its value is an opaque resource term that is never=:=by content and cannot serve as a dict/set key. Consequently the two realistic paths are (a) map-backed (#{0 => V0, ...}), canonical for free via the BEAM map-equality guarantee, zero dependencies — the low-risk default, and (b) a bespoke strict radix-balanced vector written in-tree, chosen only if the benchmark shows its memory/iteration win over maps is worth the hand-rolled code. Both are pure Erlang, no NIF. -
Reimplement
beamtalk_array.erlinternals over the chosen structure:from_list,at,at_put(now O(log n), nofrom_listround-trip),size,is_empty,do,collect,select,inject_into,includes,slice_from,print_string. Keep all#beamtalk_error{}paths and selectors identical. -
Tests (per MEMORY runtime rule — every runtime function needs EUnit coverage): port the BT-2362 regressions (structural
=:=vs literal,phash2consistency, chainedat_put) and add canonicality property tests (random construction paths to the same sequence must be=:=), plus a large-arrayat:put:-loop perf guard. -
e2e btscript (per MEMORY epic-validation rule): arrays as Dictionary/Set keys across the canonicalisation boundary; large-array update loop completing sub-second.
-
Update docs: revise the
at_put/3doc comment (drop the O(n)/O(n²) caveat), and note Array's representation indocs/beamtalk-language-features.mdif it documents internals.
Affected components: runtime/apps/beamtalk_stdlib/src/beamtalk_array.erl
(primary), plus tests. Codegen (primitives/array.rs, expressions.rs literal
path) is unaffected unless the literal builder is optimised to call a new bulk
constructor.
Migration Path
No user-facing migration. Array's public protocol and observable behaviour are
unchanged; only performance and internal term shape change. The interim O(n)
at:put: from PR #2407 is replaced wholesale. The only break is for unsupported
code that inspects the raw Erlang array term via FFI, which was never a
contract.
References
- Related issues: BT-2377 (this decision), BT-2362 (interim fix), BT-2364
- Pull request: #2407 (interim O(n) canonicalisation)
- Related ADRs: ADR 0002 (strict
=:=equality, inlined), ADR 0037 (Collection class hierarchy) - Code:
runtime/apps/beamtalk_stdlib/src/beamtalk_array.erl(at_put/3);crates/beamtalk-core/src/codegen/core_erlang/operators.rs:61;crates/beamtalk-core/src/codegen/core_erlang/primitives/array.rs;runtime/apps/beamtalk_stdlib/src/beamtalk_map.erl;runtime/apps/beamtalk_stdlib/src/beamtalk_set.erl - Prior art: Clojure persistent vectors; Scala RRB-Vector; Erlang
arraymodule internals (copy-on-write cache) - Surveyed BEAM persistent-vector libraries (all relaxed RRB, non-canonical, so
unusable for the
=:=criterion):iv(pure-BEAM, Gleam — https://iv.hexdocs.pm),arrays_rrb_vector(Elixir NIF over Rustim::Vector— https://github.com/Qqwy/elixir-arrays_rrb_vector); RRB-vector background: Bagwell & Rompf, "RRB-Trees: Efficient Immutable Vectors"