◆ Flux

collections — bounded, ordered, value-semantic

Vec is the v1 sequence kind and is already in the language core. Map, Set, Deque and Tree are sealed designs whose rollout follows v1 — collections is the first package implemented after the language core, and the order of the packages behind it is frozen.

A language usually accumulates a zoo of collections — list, dictionary, set, tuple, ordered dictionary, counter, deque, heap — because it is chasing three orthogonal axes at once: mutable versus immutable, growable versus fixed, ordered versus unordered. Flux fixes all three by construction, and the zoo collapses. What is left is one substrate — a bounded arena — plus a discipline of access: five container kinds, one vocabulary, one order.

This page specifies that framework: the substrate, the five kinds, the uniform API, where capacity comes from, why every container is ordered and nothing is hashed, why a value-semantic API costs nothing at run time, and the frozen order in which the pieces are built.

New here? Start with Guide §5 — Kinds → — it teaches vec, the fixed-capacity substrate these five kinds are built on, and why a type may not point at itself.

Why this framework is smaller here than elsewhere

Each of the three axes that produce the zoo is already decided, language-wide, before a single container exists:

Axis What Flux already decided What the axis costs here
mutable / immutable value-semantic — there is no mutation to expose no mutable/immutable split
growable / fixed bounded (A13) — every buffer has a const-folded capacity no growable/fixed split
ordered / unordered deterministic (I6/I7) — every iteration order is pinned no ordered/unordered split

Three decisions, three splits that never happen. One abstraction remains, and it shows three faces: sequence (reach an element by position), associative (reach it by key), and hierarchical (reach it by link). Five kinds cover the three faces; everything else composes out of them without a new kind.

And the value-semantic API is not a tax. Because Flux is pure, analysable and bounded, that API is executed in place (§ Efficiency): the ergonomics of a persistent collection with the performance of a mutable array — because of the constraints, not in spite of them.

The five kinds and the one substrate Figure — five kinds, three faces, one bounded arena; every canonical order is a total order on the key value, never a hash order.

The substrate — a bounded arena, and abstract kinds

Every container is a bounded arena — a vec(Slot, N) — wrapped in a structurally abstract kind: its representation is hidden. A script never matches on a container’s innards; it has only the blessed API, and that API is what maintains the invariant (the sort of a Map, the ring cursors of a Deque, the acyclicity of a Tree). Three consequences follow, and each one is a reason the design is worth the abstraction:

Parameterised, monomorphic per site. Map(K, V, N) takes two kinds and a const cardinal N, exactly as vec(κ, N) takes an element kind and a length. This is kind parameterisation — instantiated at concrete K/V/N at each use site — and it is neither row polymorphism nor first-class generics. The height of the resulting kind is finite whenever its parameters are:

height(Map(K, V, N)) = max(height K, height V) + 1

so the lattice stays finite by structural family, which is what makes join, meet and the closure laws enumerable — the same argument that already carries vec and record.

The five kinds

Kind Face Backing (hidden) Canonical order What it is for
Vec(κ, N) sequence, by index array index 0 … len−1 the primitive; the pivot every other kind projects to
Deque(κ, N) sequence, both ends ring buffer front → back O(1) at both ends: queue, stack and sliding buffer in one kind
Map(K, V, N) associative sorted array / bounded B-tree key ascending state under a dynamic key
Set(K, N) associative, no value sorted array key ascending membership and set algebra
Tree(κ, N) hierarchical node pool + handle index DFS pre-order hierarchies, with zero recursive types

Writing them down

Two spellings coexist, and it is worth separating them once:

FLUX
record Scanner {
  ema20: Map(string, price, 64)
  seen:  Set(string, 64)
  recent: Deque(num, 32)
}

Vec — the sequence

The positional face, and the one you already have: at(i), set(i, x), first / last, slice, window(n), and push / pop at the tail — the natural stack, O(1) in place. It carries the whole algebra: map / fold / scan / zip / where / mask / sortBy / topK / fill / range / setAt.

FLUX
avgVol = sma(volume, 20)
hot    = vec.where(window(volume, 64), (v) -> v > avgVol)   // vec(volume, 64) — na in the holes
top3   = vec.topK(window(high, 64), (h) -> h, 3)            // vec(price, 3)

The lambda comes before the count in topK: the key function is what the operation is about, and the count is a bound on its result. Both are the ordinary vec.* calls — the same namespace, in the same shape, as vec.map and vec.fold.

Vec is the pivot: every other container projects into it through toVec (§ The uniform API), which is why the per-container API stays small — each kind carries only its structural operations, and the rest of the algebra is reached through the bridge.

Deque — queue, stack and ring buffer, in one kind

One structure for every access discipline. The representation is a ring — ⟨buf: vec(κ, N), head, len⟩ — so at(i) is O(1) as well as both ends:

Discipline Push Pop
queue (FIFO) pushBack popFront
stack (LIFO) pushBack popBack
deque either end either end

pushFront / pushBack / popFront / popBack yield the deque without the element; the element itself is read with peekFront / peekBack, which are na on an empty deque. Splitting read from removal is what keeps every operation returning exactly one value.

FLUX
def rolling(q, x) = q.popFront().pushBack(x)   // fixed-size ring: one out, then one in

Why popFront comes first. Pushing onto a full deque is a rejection with a diagnostic, not a silent eviction — no container ever drops your data behind your back, and none ever grows. A ring that overwrites its oldest element is therefore written as an explicit pop followed by a push. Where eviction is the policy you want, it is declared as one: the Latest(n) back-pressure policy of net is a bounded queue whose eviction rule is part of its kind.

There are deliberately no separate Queue, Stack or Heap kinds. If intent needs a name, Queue and Stack are restricted-API aliases over Deque, not kinds of their own. And many “queue” needs are already met elsewhere: the Latest(n) policy is a bounded queue, the Sub / Cmd mailbox is the message queue, window(n) is the sliding buffer. The user-land Deque serves the remainder — a breadth-first frontier, a work-list, an animation queue.

A priority queue (Heap) is not a kind of its own here: topK already covers the top-k need, and the heap arrives with the graph-algorithms lot that actually requires it.

Map — the ordered associative kind

The real gap the framework fills: state under a dynamic key. Per-symbol state in a multi-asset scanner, “have I already seen this id”, counts per bucket — each of them, without a Map, is a linear scan over a slotmap or a hand-sorted vec.

The backing is a sorted array of keys and values (or a bounded B-tree, at the host’s choice):

Operation Meaning
get(k) V | na — binary search, O(log N), na when the key is absent
getOr(k, d) the value, or d when absent
has(k) signal
insert(k, v) / remove(k) a new map — executed in place when the old one is dead
update(k, (v) -> …) rewrite one entry through a lambda
keys / values / entries projections into vec, in key order
range(lo, hi, k: lit) a bounded range scan on the sorted backing — O(log N + k)
FLUX
syms   = Vec.of(["BTC-USD", "ETH-USD", "SOL-USD"])              // vec(string, 3)
def bump(m, k) = m.insert(k, m.getOr(k, 0) + 1)
counts = vec.fold(syms, Map.empty(64), (m, k) -> bump(m, k))    // Map(string, num, 64)
nBtc   = counts.get("BTC-USD")                                  // num | na

range is the operation that quietly removes a whole kind from the design. A range is also a prefix on a sorted backing, so a typeahead — the classic reason to reach for a trie — is a bounded range scan:

FLUX
def suggest(idx, lo, hi) = idx.range(lo, hi, k: 10)   // vec(record{ key: string ; val: num }, 10)

O(log N + k), a declared cap of k results, no trie kind anywhere.

The slotmap of the APP plane is the same idiom: it is a Map(Handle, V, N) whose keys the host assigns. The framework generalises the slotmap rather than duplicating it.

Set — the ordered set

A Map with the value column removed: ⟨keys: vec(K, N), len⟩. It carries has, add, remove, and the algebra that does not belong on a map — union, intersect, diff, subset.

FLUX
levels = Set.of(window(high, 64))   // Set(price, 64) — deduplicated, sorted, na skipped
known  = levels.has(close)          // signal — O(log N)
near   = levels.toVec().take(5)     // back into vec-land, and the whole vec algebra

Tree — a hierarchy as a node pool

Trees exist mostly for display — a collapsible watchlist by sector, the nested conditions of a strategy, a scene or menu hierarchy — and for any data whose internal structure is a hierarchy. A tree is never a recursive type:

FLUX
record Node { value: num ; children: Node }   // ✗ [ErrTotalType] — a type may not reference itself

A recursive type has no finite height, and a language that bounds its memory at compile time cannot admit one. So a Tree is a flat node pool — ⟨nodes: vec(Node, N), root⟩ where a node holds its value plus two handle indices (firstChild, nextSibling; na means absent). It is bounded by its node count N, from which the bound on depth follows. This is exactly what the display plane’s own BSP nodes already do: children are handles, not nodes.

Traversal is a catamorphism, and the host drives the recursion:

FLUX
record Sector { name: string ; weight: ratio }

def totalWeight(t) = tree.fold(t, (node, kids) -> node.weight + kids.sum())   // ratio

fold hands your lambda a node’s value and the already-folded children (as a vec), walking the flat array in post-order. It terminates by construction, because N bounds the pool.

Why the script never writes the recursion. A recursive traversal written in the script would be a recursive def, and the call graph must stay acyclic ([ErrTotalRec]) for the totality proof to hold. Handing the recursion to a bounded host kernel keeps both properties: the tree is walked, and the language stays total. The lambda is second-class — eliminated at the call site — so no arrow ever enters the lattice.

The rest of the surface: map, flatten(t) -> vec(κ, N) (DFS pre-order), depth, children(node), insertChild(parent, x), prune(node). Construction is Tree.node(x, kids), or Tree.unfold(seed, step, N) — the dual anamorphism, bounded by N nodes.

FLUX
leaf = Tree.node({ name: "Energy", weight: 0.18 }, [])
rows = leaf.flatten()                                     // vec(Sector, N) — DFS pre-order

The uniform API — Foldable, and the toVec bridge

You learn it once. Every container is Foldable, and toVec is the universal lens: the moment you hold a vec, the entire vec algebra (sortBy, topK, sum, where, fold) applies. That is what keeps the per-container API tiny — each kind exposes only what is structural about it, and everything else goes through the bridge.

The vocabulary shared by all five:

count(c) · isEmpty · isFull occupancy
cap(c) the capacity N — a const
fold(c, seed, step) · map(c, f) · forEach traversal in canonical order
where / mask selection, length-preserving, na in the holes
toVec(c) the bridge into the vec algebra

Per face:

Face Operations
sequence at(i) · first / last · push / pop (Vec) · pushFront / pushBack / popFront / popBack / peek* (Deque)
associative get(k) · has · insert / remove / update · keys / values / entries; Set adds add / remove and union / intersect / diff
hierarchical root · children · insertChild / prune · fold · flatten · depth

Construction is uniform too: C.of(foldable) builds a container from any foldable — a literal, a vec, another container — and C.empty(N) is the empty container at capacity N. Vec.of([…]), Set.of([…]), Map.of([(k, v) …]), Deque.empty(N), Tree.node(x, kids).

These are per-kind host routines dispatched on the kind of the receiver at the call site — the same structural dispatch the columnar Table / Col / Mat operations already use. So tree.fold(t, f) on a Tree and vec.fold(v, seed, step) on a vec are different routines behind one name, chosen by what you called it on, at compile time.

Four rules, propagated everywhere

The totality-and-determinism envelope shows up identically in all five kinds:

  1. Full ⇒ rejection and a diagnostic. Never silent growth. isFull lets you decide first.
  2. Absent ⇒ na. A missing key, an empty peek, an out-of-range index — na, never an exception. na is na-aware through the rest of the algebra, so it propagates instead of trapping.
  3. No shortening operation, ever. There is no filter: a data-dependent length would break the bound. where and mask are length-preserving and leave na in the holes, and iteration is na-aware, so nothing needs compacting.
  4. A canonical order per container (§ Determinism).
FLUX
avgVol = sma(volume, 20)
hot    = vec.where(window(volume, 64), (v) -> v > avgVol)   // ✓ same length, na where the predicate is false
FLUX
hot = vec.filter(window(volume, 64), (v) -> v > avgVol)  // ✗ no such operation — the length would depend on the data

Capacity comes from the context

The hard line of the bounded-memory rule, stated once:

FLUX
n    = count(volume > sma(volume, 20), 64)   // a RUN-TIME value: it depends on the data
seen = Set.empty(n)                          // ✗ [ErrTotal] — a capacity must const-fold

Why “it depends on the context” is not the same as “it depends on the data”. The context fixes the const ceiling — how many bars this chart holds, how many levels this tool allows. The data fixes the occupancy under that ceiling. Keep those two apart and the memory a script needs is computable before it runs; conflate them and it is not.

Determinism — ordered by default, zero hashing

Every container has a canonical order, used by toVec, by fold, and by iteration, because I6/I7 demand that two engines produce the same bytes.

Kind Canonical order
Vec index order
Deque front → back
Map / Set key ascending
Tree DFS pre-order (children in insertion order)

Map and Set are sorted by key — the associative kinds are ordered structures, not hash tables. Three reasons, in the order they mattered:

Why a hash order is not an option. A hash order depends on a seed and on the insertion history, so two engines can iterate the same set in two different orders while both being “correct”. Under I7 — interpreter ≡ compiled module, byte for byte — and under a replay that a server re-derives to check a client’s work, “both correct” is a divergence. Sorting the keys costs O(log N) on lookup and buys back the entire property, with nothing to pin.

Keys must be comparable. A key kind must admit a total order and an equality ([CmpOrd] / [CmpEq]): string, num, dir, decimal, and records of those. Kinds with no equality — clock, ui, a lambda — cannot be keys:

FLUX
record Bad { picked: Set(ui, 8) }   // ✗ [ErrArg] — `ui` has no equality, so it cannot be a key

An insertion-order or pinned-hash (seedless) backing remains available as a host implementation choice behind the same API, should a key-unordered or insert-heavy profile ever demand it. The exposed order stays deterministic either way; the decision is evidence-based, and sorted-by-default is the shipping answer.

Efficiency — a value API, executed in place

This is where the constraints pay for themselves. A bounded immutable collection sounds slow. It is not, and the reason is mechanical.

Functional but in place. The API returns a new container — m2 = m.insert(k, v) — but the compiler already holds the DAG of uses. When m is dead after the call, the host mutates the arena in place: O(1) or O(log N), not an O(N) copy. No new machinery is involved; this is the existing liveness analysis (persistent-held versus transient-recycled buffers) applied to container updates. Nothing is annotated, nothing is borrowed — it is inferred.

FLUX
def track(book, sym) = book.insert(sym, ema(close, 20))   // returns a new Map…
                                                          // …and updates the arena in place when the old one is dead

It is observably pure either way: in-place and copy produce the same value, so I6/I7 hold and the bytes are identical. The optimisation is invisible except in the profile.

No persistent trie. Unbounded immutable collections need a hash-array-mapped trie to be efficient — that is what pays for structural sharing. Bounded arenas plus in-place execution get the same effect more directly: a contiguous array, mutated in place, no garbage collector, no pointer chasing, and linear scans that are cache-friendly.

Zero allocation in steady state. The ceiling N pre-sizes the arena, so the arena is allocated once. Even the worst case — an actual copy — is a fixed-size memcpy.

toVec is often free. Where the backing already is the vector, toVec is a view, not a copy. Only a reordering (a sorted view of an insertion-ordered backing) materialises anything.

Conversions and compositions

The of / toVec bridge makes conversion mechanical, and the capacity comes along for the ride:

From → to How Semantics
vec → set v.toSet() / Set.of(v) deduplicate, sort, skip na; cap = the source cap
set → vec s.toVec() sorted
vec → map v.toMap((x) -> key(x)) / Map.of(pairs) key derived; on collision the last wins
map → vec m.entries() / m.keys() / m.values() key order
vec → deque Deque.of(v) front → back = source order
tree → vec t.flatten() DFS pre-order

Everything else composes, and adds no kind:

FLUX
type Graph  = Map(num, Set(num, 16), 64)       // bounded adjacency, by handle
type Counts = Map(string, num, 64)             // a counter / bag
type Index  = Map(string, Set(num, 32), 256)   // an inverted index: token → document ids

A bounded graph walk is a bounded loop over exactly two of these kinds — a Deque frontier and a Set of visited nodes:

FLUX
record Walk { frontier: Deque(num, 64) ; seen: Set(num, 64) ; order: vec(num, 64) }

Breadth-first, depth-first, topological order, connected components and cycle detection are all defs over that state. A multimap is Map(K, vec(V, M), N). A slotmap is Map(Handle, V, N). None of them is a new kind.

The compute pillar stays a separate family. Table / Col / Mat are columnar and relational — groupBy, asofJoin, stat, regression — and they are not forced into the container vocabulary, any more than a dataframe should be forced into a dictionary. The bridge between the two worlds is Col ↔ Vec: a column is a sequence. See compute.

Planes, the firewall and replay

Containers are pure values, so they cross no plane boundary and raise no firewall question:

FLUX
record Level { id: num ; price: price ; label: string }
record Model { levels: Map(num, Level, 64) ; count: num }

No container operation reads presentation, and none reads a device-variable value, so none can breach the firewall ([ErrFirewall] is not reachable from this API — the firewall and the capability model it enforces are specified in host-services). Every operation is a pinned routine with a canonical order, so byte-identical replay — and the anti-cheat that rests on it — holds through any container; what a server re-derives from that replay, and the guarantee it proves, is server’s to state. The lambdas passed to fold / map / where are second-class, eliminated at the call site, so no arrow sort enters the lattice.

Deliberate limits

These are design decisions, not gaps:

Five kinds, one vocabulary (Foldable plus toVec), value-semantic and executed in place, ordered and deterministic, bounded — and every one of those properties is a consequence of a decision the language had already made.

See also