Français
streams · events one dataflow VM — the FVM many apps

Flux & the FDK

The complete documentation of Flux — a general-purpose, deterministic dataflow language whose single VM stands behind many kinds of application: live charts, interactive interfaces, animated scenes, server-checked games. Inside: the type system, the four planes, the runtime, and every FDK pillar — one self-contained file, every figure inlined, nothing from the network.

Flux — Language & FDK documentation

Flux is a total, causal, deterministic application language for the web platform. A Flux program is not a sequence of instructions — it is a typed dataflow graph: every value is a stream, every stream carries a kind (its dimension, not only its shape), and the engine — not the author — owns evaluation, incrementality, memory and scheduling. Four cooperating planes separate what a program computes (ANALYSIS) from what it shows (CANVAS), how it animates between states (TRANSITION), and how it interacts and persists (APP) — with a one-way firewall between them that makes the core guarantees structural rather than disciplinary.

The language ships with the FDK (Flux Development Kit): the standard prelude and the pillar APIs — compute (dataframes, statistics, domain math), collections, color, text, i18n, units, networking, display, host services, the server plane, and asset/currency identity — all bounded, capability-gated, and specified to the same determinism bar as the core.

One dataflow VM stands behind many kinds of application — a live chart, an interactive interface, an animated scene, a server-checked game — because the four planes span compute → show → animate → interact. Flux is general-purpose; its domain specializations ride on top, and the flagship is financial charting and market analytics, which is why many examples plot prices — though nothing in the model is specific to markets. One line is enough to see the character of the language:

fluxplot rsi(close, input(14))   // osc(0,100) → its own pane, 0–100 scale, 30/70 guides, params UI — all inferred

The four planes
Figure — the four planes and the one-way firewall.

#Reading paths

#The map

#The book

Page Covers
01 — What is Flux The mental model, what programs look like, where they run, trust tiers, honest non-goals
02 — Design pillars Total · causal · byte-deterministic · dimensional · fire-walled · capability-secure · optimizable
03 — Getting started From one line to parameters, composition, state, MTF, canvas and a first app
04 — The four planes ANALYSIS / CANVAS / TRANSITION / APP in depth; the firewall; live()

#Language reference

Page Covers
Lexical structure Tokens, literals, interpolation, keyword tiers, newline rules
Grammar The normative grammar, the single arrow's five readings, precedence, formal properties
Kinds The dimensional type system: sorts, lattice, coercion, tags, named types
Operators The dimensional algebra of every operator; UFCS; with; the ? family
Inference Kind inference; presentation inference (kind → pane/scale/guides); the error policy
Time and state Streams, delay, windows, scan, clocks and @, causality, live()
Canvas Signals as properties, spaces, events → actions, primitives, the performance model
Transitions Morph, replay, focus, view — cosmetic interpolation between computed states
App plane The Elm-architecture core, messages, subscriptions, commands, slots, capabilities

#FDK reference

Page Covers
FDK overview Prelude, namespaces, doc-as-data, the capability model, status matrix
compute math/stat/vec/decimal/time/ta, Table/Col/Mat dataframes, domain libraries
collections Vec/Deque/Map/Set/Tree — bounded, ordered, value-semantic
color The color kind, construction, OKLab interpolation, output channels
text string, str/fmt, the Md codec, editing, highlighting, diff, search, validators
i18n Locales, message catalogues, MessageFormat 2, collation, RTL
units meas[u] quantities: conversions, affine point/delta, walls
net Verbs, transports, codecs, streams, backpressure, presets, cache/offline
display Scenes as values, the two strata, 2D/3D models, viz.*, panes, tool bindings
host services Files, clipboard, notifications, auth, payments, media, print, fonts, embedding
server Headless apps, shared storage with tiered ACLs, prerender, the third determinism leg
asset & currency Asset tags (B,Q[,@v]), fx and money, venues, toSource

#Internals

Page Covers
Compiler & runtime The pipeline, interpreter ≡ WASM (I7), pinned routines, budgets, the verification harness
Memory model Value representation, SoA columns, the liveness plan, slotmaps, arenas, linear memory
Optimizer The correctness law, translation validation, tiers T0–T3, the cost model
Concurrency Scheduling the pure DAG, barriers vs work-stealing, 1 ≡ N byte-equality
Packages fluxpack, content addressing, minimal version selection, capability aggregation
Host integration The five descriptors, open registries, representations, extension seams

#Guides

Page Covers
Cookbook Working recipes for every aspect of the language
Guarantees What is guaranteed, and how each guarantee is enforced and machine-verified
Editor Kind-aware completion, hover documentation, live preview, dataflow debugging
FAQ Common questions answered by the model; deliberate non-goals
Glossary Every term, precisely defined

#Implementation status

The documentation describes Flux v1 as specified by the sealed design plans (docs/DESIGN-flux-*.md). Status at a glance:

Area Status
ANALYSIS-plane core — grammar (Lezer), kinds & inference, DAG interpreter, optimizer with translation validation, golden corpus & differential tests Implemented (src/flux/lang)
WASM backend (Binaryen), multi-worker execution (shared memory, atomic ready-counters), chart wiring Implemented (src/flux/lang, src/flux/display)
fluxpack distribution format — writer, loader, manifest, verification, rebuild gate Implemented (src/flux/pack)
CANVAS / TRANSITION planes Sealed design; implementation staged
APP plane (TEA harness, capabilities, slots) Sealed design; implementation staged
FDK pillars (collections first, then per the frozen order) Sealed designs; implementation staged

Individual pages do not repeat this table; features the design itself defers are labelled Post-v1, Reserved, or Open decision inline (see the conventions below).

#Conventions used throughout

#See also

↑ contents

What is Flux

Flux is a total, causal, deterministic application language for the web platform. It is a general-purpose language with domain specializations — financial charting and market analytics are the flagship specialization, not the definition. You can write an indicator in one line, an animated scene in five, or a complete interactive application in fifty; all of them are the same kind of object: a pure dataflow graph over typed streams, executed by a host that guarantees — by construction, and verified by machine — that the program terminates, never rewrites its own past, produces the same bytes on every machine, and can touch nothing it was not explicitly granted.

This chapter gives you the shape of the whole language: the one idea underneath it, the four planes a program is made of, what programs look like, where they run, whom they are for, and what Flux deliberately does not do. Everything here is unpacked in later chapters; nothing here requires prior knowledge of Flux or of trading.

#One idea: every value is a stream

A Flux value is not a number sitting in a variable. It is a stream: a value as it evolves along an ordered axis of data units — sensor readings, log entries, game turns, or (in the flagship domain) market bars. close is not "the latest price"; it is the whole history of closing prices, one value per unit, up to now.

Everything follows from taking that seriously:

fluxfast = ema(close, 12)          // a stream: the 12-unit exponential average, over all history
slow = ema(close, 26)
plot fast - slow               // element-wise difference — itself a stream

Because streams are values, ordinary functional composition is the whole programming model: functions from streams to streams (def), records of streams (bb.upper), streams of records, streams driving visual properties. There is one algebra, applied everywhere.

#Describe an expression; the engine evaluates

The mental model in one sentence: you describe a pure expression, the engine evaluates it. You never write the loop, the buffer, or the await. The compiler inlines your definitions into a typed incremental DAG (directed acyclic graph) of operations, checks it — kinds, causality, totality, plane boundaries — chooses a native kernel for each node it recognizes, plans its memory statically, and then runs it either per unit (live, one step per new data unit) or in batch (full-history replay). Both runs are the same function and produce the same bytes.

This is why a Flux program has no lifecycle code. There is no "on new bar" callback, no subscription management inside ANALYSIS code, no cache invalidation: the DAG is the dependency graph, and the engine advances it. It is also why the engine can make strong promises — a pure, typed, bounded, acyclic graph is an object you can verify, schedule, parallelize and optimize without changing its meaning.

#Four planes, one one-way firewall

A complete application has parts with very different needs. A computation must be exact and reproducible. An animation must read the clock and may use randomness. A screen transition must be able to interpolate freely without corrupting data. Application state must respond to user events and drive effects. Flux does not average these needs into one compromise; it separates them into four cooperating planes — one language, one expression algebra, four sets of rules:

Plane Role Clock Rules
ANALYSIS computation over data units: indicators, signals, representation transforms the data unit (the bar) total, causal, deterministic, no-repaint, sandboxed
CANVAS presentation: animated drawing, decor, effects, pointer interaction the frame screen, wall-time and randomness allowed — outside the guarantees, by design
TRANSITION interpolates the render between two computed states the frame cosmetic by construction: it can never change a value
APP application state and UI: Model · update · view · Sub / Cmd events pure, total, deterministic update; replayable message journal; capability-gated effects

The four planes
Figure — the four planes: computation on the left, presentation on the right, applications below, and the one-way firewall between them.

The planes are joined by a firewall with a single direction: presentation may read analysis; analysis never reads presentation. A CANVAS scene may glow brighter when an ANALYSIS value rises; an ANALYSIS expression that tries to read the mouse, the wall clock or an unseeded random signal is rejected at compile time with [ErrFirewall]. The APP plane reads series through typed subscriptions and can never write into ANALYSIS.

Why this rule exists. The firewall is what lets guarantees and freedom coexist in one program. Your signal remains provably no-repainta value, once produced for a step, never changes — even while an animated, randomized effect dances right next to it, because the language makes it impossible for the effect to feed back into the signal. Every value that matters lives on the disciplined side of the wall; everything decorative lives on the free side.

Chapter The four planes covers each plane in depth.

#What a program looks like — three tastes

#An analytic, in one line

fluxplot rsi(close, input(14))     // rsi : osc(0,100) → own pane, 0–100 scale, guides 30/70

That single line is a complete, shippable program. Everything else is inferred from the kind of the expression — the dimensional type that says what the value is physically, not merely how wide it is:

Nothing about placement, scale, reference lines or parameter plumbing is written anywhere, and none of it needs to be. When you do want control, every one of these defaults is overridable in place — see Getting started.

#A scene that moves

fluxcircle { at:(spring(close)); glow: 16; trail: 24 }         // a comet easing toward the price
on every(1 bars) -> spawn ring { r: 6->24; life: 200 bars }

This is the CANVAS plane. Its axiom: every property is a signal. A constant, a data stream, and an animation generator like spring(close) — a signal that continuously eases toward its target — are the same kind of thing and combine with the same algebra. There is no separate animation API to learn: you wire signals into properties, and events (on … ->) spawn or tween primitives. The scene compiles once into a retained structure; the host routes each signal to the cheapest execution path (cached, per-unit, or compositor-driven).

#An application

fluxvariant Msg { Inc | Reset }
app counter {
  init(p) = { n: 0 }
  update(m, msg) = match msg {
    Inc   -> { model: m with { n: m.n + 1 }, cmds: [] }
    Reset -> { model: m with { n: 0 }, cmds: [] }
  }
  view(m) = panel(slot: right.panel) { text("count {m.n}") ; button("+1", Inc) }
  subs(m) = []
}

This is the APP plane: The Elm Architecture (TEA), hardened. A Model (bounded, typed state), a pure and total update that folds messages into new state plus a list of commands — inert effect descriptions the host may execute — a pure view that returns a tree of vetted UI primitives, and declarative subscriptions (subs) through which the ambient world (time, data, user gestures) enters as messages. Because update is a pure fold over a message journal, application state is replayable by construction: the journal is the single source of truth. This app requests no capabilities, so the host will let it do nothing beyond drawing its view and receiving its own messages.

#One arrow, five readings

You have now seen the arrow -> three times, meaning three different things — an event's action, an arm of a match, and (in the next chapter) a lambda. That is deliberate, and it is the one piece of syntax worth learning up front, because Flux has exactly one arrow token and it carries five readings, each selected by its context:

The five readings of the arrow
Figure — one token; the head keyword, or the kind the position expects, decides the reading.

Reading Example What selects it
lambda vec.map(v, (x) -> x * 1.1) the position expects a function
event → action on click -> burst(40) ring { } the head keyword on
tween pair tween r 6 -> 24 over 300ms no function is expected — the arrow pairs two values
match arm match m.phase { ask -> … } the head keyword match
view comprehension for lvl in levels -> dot { … } the head for … in

The parser never has to guess: a keyword head claims the arrow, and everywhere else the kind of the position decides. The reward is that you never carry a table of arrow-like symbols in your head — there is one, and you read it from its context.

#Where programs run

A Flux program has two execution vehicles and one meaning. While you edit, an interpreter evaluates the DAG directly — instant feedback, per-node values the debugger can read, live preview on every keystroke. When a program runs for real — or ships to someone else — it is compiled to a WebAssembly module and executed in a sandbox. The two are not "close": they are bit-identical. Invariant I7 requires the interpreter and the compiled module to produce exactly the same bytes on the same inputs, and the toolchain verifies this at every compilation by running both and asserting equality — a divergence blocks the artifact from shipping. The same discipline extends across machines: floating-point evaluation is scalar and unreassociated, and every routine with room for platform variance (transcendental math, decimal arithmetic, Unicode, calendars, random generation, the representation of missing values) is pinned to one shared implementation. What you saw in the editor is what runs, everywhere, to the last bit. The machinery is specified in Compiler and runtime.

#The language of a platform: two trust tiers

Flux is not an embedded extension niche; it is the language the platform itself is written in, and the language its users extend it in. Both audiences share one language and one sandbox, distinguished only by trust — the capabilities granted, never the code:

Post-v1. The public sharing and marketplace rollout is deferred; the trust model that makes it safe is v1 and is not weakened anywhere.

#What Flux deliberately does not do (v1)

Honest limits are part of the design. Each of these is a choice with a rationale, not a gap:

#How to read this documentation

The book (this section) builds the mental model in order:

  1. What is Flux — this chapter.
  2. Design pillars — the seven guarantees, why each exists, and what each buys you.
  3. Getting started — a guided first session, from one line to a small application.
  4. The four planes — ANALYSIS, CANVAS, TRANSITION and APP in depth, including the firewall and live values.

Around the book:

If you read only one more page, read Design pillars: every design decision in the language traces back to one of the seven.

#See also

↑ contents

Design pillars

Every design decision in Flux traces back to seven pillars. Each pillar is a property the language enforces by construction — not a convention, not a linter rule, not a best practice, but something the compiler proves about every accepted program and, where proof needs help, verifies by machine at every compilation. This chapter states each pillar, explains why it exists, spells out what it buys you concretely, and shows it in a micro-example.

Read this chapter to understand why Flux is shaped the way it is; read Getting started to feel it in practice.

The seven pillars
Figure — seven pillars supporting one guarantee set: bounded cost, immutable history, identical bytes on every engine, inferred presentation, contained effects, and an optimizer you need not trust.

#1. Total, not Turing-complete

The statement. Every Flux program terminates, and its cost per step is known at compile time. There are no unbounded loops, no unbounded recursion, no unbounded collections.

Why. Flux belongs to the synchronous-dataflow family (in the lineage of Lustre and SCADE): languages built for systems where "the program might not finish this step" is not an acceptable outcome. A charting host must advance every active script on every data unit inside a frame budget; a platform that runs other people's code must bound what that code can consume. Turing-completeness would remove exactly these guarantees — the halting problem makes an unbounded script impossible to budget — and buys nothing that real analytical programs need. Totality is a choice, and Flux makes it openly.

What it buys you.

In practice. Iteration exists — bounded, and honest about it:

fluxhi20 = highest(close, 20)                        // windowed reducer — the bound is a constant
w    = window(close, 20)                         // the same 20 values as a vec, for map/fold
hi   = scan(close, (prev) -> math.max(prev, close))   // running state with feedback (see pillar 2)

window with map/fold covers counted loops, scan covers running state, and loop(max, …) covers "iterate until done" with a declared ceiling. Every bound is a compile-time constant under a global cap; a data-dependent length is a kind error, not a runtime surprise.

#2. Causal by construction

The statement. A value produced for a step can never change afterwards. History is immutable — not as a discipline the author maintains, but as a theorem of the language.

Why. In any system that re-evaluates over growing history, the deadliest defect is the value that quietly changes retroactively: an analytic that looked prophetic on history because, at each past step, it had silently read data that did not exist yet. The result is a live/replay divergence that no test catches, because both runs are "correct" — for different definitions of time. Flux removes the defect at the root by making it inexpressible: no construct in the language can read the future.

Three rules produce the theorem:

By induction over the graph: output[t] = f(inputs[0..t]), mathematically. A past value has nothing left to depend on, so nothing can move it. This property is called no-repainta value, once produced for a step, never changes — and it holds for every accepted program, not for a careful subset.

What it buys you.

In practice.

fluxprev  = close[1]                 // yesterday's close — legal, and na on the first bar
gain  = math.max(close - prev, 0)
peek  = close[-1]                // ✗ [ErrCausal] — a negative delay reads the future

The rejection comes with its reason: a value from the future would force your past values to change once reality catches up.

#3. Deterministic to the byte

The statement. The same program on the same data produces the same bytes — between the editing interpreter and the compiled WASM, between two runs, between two machines.

Why. "Roughly equal" is not a property you can build on. Byte-equality is: it makes replay exact, golden tests meaningful, results reproducible across devices, and independent re-execution (for example, a server re-checking a client's run) possible at all. Floating point is deterministic if and only if every source of variance is pinned — so Flux pins all of them, as language policy rather than author burden.

What is pinned.

The equivalence is not assumed; it is verified at every compilation by running the interpreter and the compiled module on real data and asserting bit-equality (invariant I7). A divergence blocks the artifact.

What it buys you.

In practice.

fluxr = math.log(close / close[1])        // ratio in, dimensionless out — via the pinned libm, same bits everywhere
x = rand(42)                     // seeded: deterministic, replayable, admissible in ANALYSIS

Unseeded randomness exists — on the presentation side of the firewall (pillar 5), where determinism is deliberately not promised.

#4. Dimensional kinds

The statement. Every stream carries a kind — a dimensional type that records what the value is physically, not merely that it is a number. The kind system computes the kind of every result and rejects operations with no physical meaning.

Why. In analytical code, the worst bugs are not type errors a conventional checker would catch — everything is a float. They are dimension errors: adding a price to an oscillator, comparing a volume to a ratio, feeding a percentage where a level is expected. All of these are well-typed nonsense in a float-only world. Flux gives data its physics back. The price axis is modeled as an affine space: price is a point, level is a displacement (vector), ratio is a dimensionless scalar; dimensions form an algebra under the operators. The pleasant consequences are theorems, not special cases:

fluxrange = high - low                                //   price − price → level  (point − point = vector)
band  = sma(close, 20) + 2 * stdev(close, 20)     //   price + level → price  (point + vector = point)
rel   = close / open                              //   price ÷ price → ratio  (dimensionless)
bad   = close + rsi(close, 14)                    // ✗ [ErrDim] — point + dimensionless: no affine meaning

What it buys you.

Kinds are the keystone the other pillars lean on: the memory planner sizes buffers from kinds, the optimizer's cost model reads them, and the editor's completion filters by them. The full system — sorts, the lattice, coercion, operator algebra — is specified in Kinds.

#5. Planes with a one-way firewall

The statement. Computation and presentation are separate planes, and dependence crosses between them in exactly one direction: presentation may read analysis; analysis may never read presentation. Violations are compile-time errors: [ErrFirewall].

Why. A language that promises determinism and wants delightful, animated, interactive output has a problem: screens, wall clocks, pointers and randomness are exactly the things determinism must exclude. The usual outcomes are grim — either the guarantees quietly erode ("mostly deterministic"), or the output layer is starved into lifelessness. Flux refuses the dilemma structurally. Everything non-deterministic — now(), screen coordinates, hover state, unseeded rand/noiseexists, but only on the presentation side (CANVAS, TRANSITION), where nothing downstream depends on exactness. The ANALYSIS and APP planes stay inside the guarantees. The wall between them is directional and compiler-enforced — see the figure in What is Flux.

What it buys you.

In practice.

fluxdot { at:(bar.i, close); r: 4 }       // CANVAS reading an ANALYSIS value — the legal direction
x = ema(now(), 20)                    // ✗ [ErrFirewall] — wall-clock time cannot enter ANALYSIS

The same rule gives live(e) — the presentation-side read of the unit still in formation — a safe home: it may flow to display sinks, never into confirmed analysis. Details in The four planes.

#6. Capability security

The statement. Scripts have no ambient authority. Effects are default-deny object-capabilities: a script can affect the world only through capabilities it declared, that the user granted, and that the host mediates.

Why. Flux is the language of a platform where code is shared, sold and run by people who did not write it. That is only tenable if safety is a property of the language and host, not of a review process. Flux gets there in layers: the language has no primitive that does I/O — no fetch, no DOM access, no eval, no file handles — so a script's only channel to the world is data it hands the host. On the APP plane that channel is explicit:

fluxapp quiz {
  capabilities: [ chart:read, sfx, storage:own ]   // everything this app may ever touch
  // ... a Cmd like PlaySfx("ding") is data; the host decides whether it runs
}

What it buys you.

#7. Optimizable by construction

The statement. A Flux program is a pure, typed, total, causal DAG — the form on which classic optimizations are safe by construction. And the optimizer is verified at every compilation, never trusted.

Why. In impure languages, optimizers spend their sophistication proving that a transformation cannot observe an effect — and give up conservatively when they cannot. Flux programs have no effects to observe: any two computations of the same pure subgraph are interchangeable, so sharing, pruning, reordering and specializing need no heroics. The program's small, bounded size adds a second, unusual advantage: whole-graph searches that are infeasible on large programs are affordable here, so the optimizer can aim for optimal rather than "good enough".

The trust model is the distinctive part. The optimizer obeys one law: the optimized program must be bit-identical to the reference semantics — the unoptimized DAG's canonical evaluation (or the native kernel, for built-ins). That law is enforced by translation validation: every compilation runs reference and optimized artifacts on real data and asserts byte-equality. The optimizer may therefore be aggressive precisely because no one has to believe in it — a miscompilation cannot ship, it can only fail loudly at the gate.

What it buys you.

In practice.

fluxdef ema0(s, n) = let a = 2/(n+1) in scan(s, (p) -> a*s + (1-a)*p)
def macd0(s)   = let l = ema0(s, 12) - ema0(s, 26) in { macd: l, signal: ema0(l, 9), hist: l - ema0(l, 9) }

plot macd0(close).macd, macd0(close).signal    // two calls, one shared subgraph — CSE, verified bit-exact

The tiers, the cost model and the validation harness are specified in Optimizer.

#How the pillars compose

The pillars are not seven independent features; each one leans on the others, and the guarantee set holds because the loop closes:

Pull any one pillar out and the others weaken; together they close: describe an expression, and the engine can evaluate it — provably terminating, never rewriting history, identical everywhere, disclosing everything it touches, at a cost known in advance.

#See also

↑ contents

Getting started

This chapter is a guided first session. It starts with the shortest program that does something real, and grows it — parameters, styling, composition, state, multiple clocks, a moving scene, a small application — explaining what the language did for you at each step and, just as importantly, what it refused to do.

Everything here is a complete program. Paste any of it into the editor and it runs.

#One line

fluxplot rsi(close, input(14))

That is a complete, publishable analytic. Nothing was configured, and yet:

What one line produces
Figure — one source line; the kind decides the rest.

Delete input(…) and write plot rsi(close, 14): same analytic, no control. The input wrapper is how a value becomes a knob.

What just happened. You did not choose a pane, a scale, a colour or a reference line. The kind of the expression carried all of it. This is the single biggest ergonomic consequence of a dimensional type system, and it holds for everything you write next.

#Parameters and styling

input accepts a default, an optional range, and optional metadata:

fluxlen  = input(14, 2..200, title: "Length")
src  = input(close, title: "Source")
show = input(true, title: "Show band")

plot rsi(src, len)

The kind of the default decides the widget: a number gives a numeric field (a range makes it a slider), close gives a source picker, true gives a checkbox, a list of strings gives an enumeration.

Presentation defaults are inferred, but intent always wins:

fluxm = macd(close)
plot m.hist { style: histogram, color: if m.hist > 0 then up else down }
plot m.macd, m.signal
plot ema(close, 200) { overlay }        // it is already a price — this is explicit
plot rsi(close, 14) { guides: [20, 80] } // your own reference lines, kind-checked

The style values are a closed set (histogram, columns, stepline, area, circles, cross); a line is the default a level or a price infers. Forcing a level onto the chart with { overlay } gives it its own secondary axis — the compiler knows a shared price scale would flatten it to nothing.

#Composition

def defines a pure function from streams to streams. It is inlined into the graph, so there is no call cost to think about:

fluxdef zscore(x, n = 20) = (x - sma(x, n)) / stdev(x, n)

plot zscore(close)          // (price − price) ÷ level = level ÷ level → ratio
plot zscore(hlc3, 50)       // the same def on another price source — the kinds follow the argument

Any function can be written as a method-style chain — the receiver becomes the first argument — which is how most people end up writing analytics:

fluxsmoothRsi = close.ema(20).rsi(14)     // ≡ rsi(ema(close, 20), 14)

The payoff is not brevity. After you type close., the editor offers only the functions whose first parameter accepts a price — the type system becomes a discovery mechanism.

Several kernels return a record, and you project the field you want:

fluxbb = bollinger(close, 20, 2)
plot bb.upper, bb.middle, bb.lower
fill bb.upper..bb.lower      // a band: both operands are `price`, so the fill is well-formed

fill bb.upper..rsi(close,14) would be ✗ [ErrDim] — you cannot shade the region between a price and a dimensionless oscillator, and the language says so rather than drawing nonsense.

#Signals, marks and alerts

A comparison produces a signal, and a signal is presented as marks — never as a line:

fluxcross = close cross_up ema(close, 50)

mark cross "crossed at {fmt.price(close)}"
alert cross "EMA-50 crossed up"

Strings interpolate, so a label or an alert message can carry live values. cross_up is an infix operator: it is true on the bar where the left side rises through the right side, and its definition — like everything else — reads only closed data.

#State

You never write a loop over bars. When a value depends on its own past, you write a scan: a seed, and a step that receives the previous state.

flux// a running maximum since the start
def runMax(x) = scan(x, (prev) -> math.max(prev, x))

// a trailing stop that only ever ratchets upward while long
def trail(mult) =
  let stop = close - mult * atr(14) in
  scan(stop, (prev) -> math.max(prev, stop))

plot trail(3)               // price − lit × level = price → it overlays

The step function sees prev — the state at the previous bar — and the current bar's values. That is the whole model: state advances one step per bar, through a unit delay, which is precisely why a value once emitted can never be rewritten.

Composite state is a record, and a state machine is a variant plus a match:

fluxvariant Trend { Up | Down }

def step(p, n) = match p.dir {
  Up   -> if close < p.ref - atr(n) then { dir: Trend.Down, ref: close } else p
  Down -> if close > p.ref + atr(n) then { dir: Trend.Up,   ref: close } else p
}

def flip(n) = scan({ dir: Trend.Up, ref: close }, (p) -> step(p, n))

match is the eliminator of a variant, and it must cover every constructor — an uncovered case is [ErrTotalMatch], a compile error, not a runtime surprise.

#More than one clock

The step axis of a series is a value, of kind clock. @ reads an expression on another clock, and it reads only closed units of it:

fluxplot ema(close, 20) @ tf("1h")                 // hourly EMA, shown on whatever chart you are on
color bars: if close > ema(close, 50) @ tf("1d") then up else down

That second line is the confluence idiom: a fast chart, coloured by a slow trend. It is not a special feature — it is the ordinary resample, and it is causal, so what a bar showed yesterday it still shows today.

Price-driven step axes are clocks too (renko(box), pnf(box, rev), range(r)), which is why alternative chart representations are not a separate subsystem: they are a different clock.

#A scene that moves

Presentation lives on its own plane, where every property is a signal — a constant, a data value and an animation are the same kind of thing, so there is no animation API to learn:

fluxcircle {
  at:    (bar.i, spring(close)),   // the position eases toward the data
  r:     6,
  glow:  16,
  trail: 24
}

on close cross_up highest(close, 250)[1] -> burst(40) ring { r: 6 -> 24, life: 2s }

The scene may read analysis values. Analysis may not read the scene — that one-way rule is what keeps the moving parts from ever touching the numbers. Try it and you get [ErrFirewall], with an explanation.

#A small application

The fourth plane adds state that persists between events and decides what is displayed — a model, a pure update, a pure view, and declarative subscriptions. Effects are inert data the host executes; capabilities are default-deny.

fluxvariant Msg { Tick | Reset }

app counter {
  capabilities: [ ]

  init(p)          = { n: 0 }
  update(m, msg)   = match msg {
                       Tick  -> { model: m with { n: m.n + 1 }, cmds: [] }
                       Reset -> { model: m with { n: 0 },       cmds: [] }
                     }
  view(m)          = row {
                       text("count: {m.n}")
                       button("reset", Reset)
                     }
  subs(m)          = [ OnTick(1000, Tick) ]
}

OnTick(1000, Tick) reads as "every second, apply the Tick constructor". A subscription carries the constructor the host will wrap its payload in — that is how an event knows which arm of update it belongs to, without any function value ever entering the language.

update is pure and total: it cannot read the clock, cannot reach the network, and must handle every message. Everything ambient — time, randomness, data, input — arrives as a message, which is exactly why an application can be replayed message by message and why its tests are goldens over pure functions.

#What the editor is doing while you type

Kind-filtered completion after ., a hover card with the signature and a live sparkline of the expression on the current data, diagnostics with quick-fixes, a preview that re-evaluates the typable part of your program on every keystroke (a half-typed name blanks one value, never the screen), and a dataflow view that answers "why is this signal true here?". See the editor.

#See also

↑ contents

The four planes

A Flux program is not one kind of thing. Computing an indicator, drawing a comet that follows the price, morphing a chart when the asset changes, and running a stateful application in a pane are four different activities with four different clocks and four different sets of rules. Most languages give you one plane and ask you to be careful. Flux gives you four, and makes the boundary between them a property of the type system.

The four planes
Figure — the four planes and the one-way firewall.

The payoff is a sentence worth reading twice: a value can be animated, random, and frame-dependent, and still be provably incapable of changing a computed number. Not by convention. By construction.

A note on the samples. Flux has no expression-statements, so a bare expression is not a program. The lines marked on this page are therefore expression fragments: they exist to show what the kind rules refuse, not what the parser accepts. Every unmarked line is a legal statement.

#ANALYSIS — the plane that computes

Clock the bar — it advances only on closed data
Guarantees total, causal, deterministic, sandboxed, no-repaint
What lives here indicators, signals, representation transforms, the numbers a decision rests on

The analysis plane is the most constrained, and therefore the most trustworthy. Everything in it is a pure function of the past: delays reach backwards only, resampling reads only closed units, feedback must cross a unit delay. What follows is not a promise but a theorem — a value, once produced for a bar, can never change.

fluxplot rsi(close, 14)
plot ema(close, 20) @ tf("1h")            // a coarser clock — still causal
mark close cross_up ema(close, 50)

Analysis reads nothing from the planes above it. There is no symbol for the mouse, for the wall clock, for the current frame, or for whether 3-D mode is on. Not "it is bad practice" — those names do not exist in the analysis namespace.

#CANVAS — the plane that shows

Clock the frame
Allowed screen space, wall-clock time, randomness — explicitly outside the guarantees
What lives here scenes, animated drawings, decoration, effects

The canvas plane may read analysis. It may not write it.

fluxcircle {
  at:    (bar.i, spring(close)),   // reads analysis; the easing is cosmetic
  glow:  16 + 8 * throb(0.4),      // per-frame, time-only — the compositor owns it
  trail: 24
}

on close cross_up highest(close, 250)[1] -> burst(40) ring { life: 2s }

Every property is a signal — a constant, a data value and an animation are the same kind of thing here, so there is no animation API to learn. And because the compiler knows which signals are time-driven, it routes them to the host compositor: zero JavaScript per frame for the parts that move the most.

#TRANSITION — the plane that interpolates

Clock the frame
Rule it interpolates the rendering between two already-computed states
Consequence it is cosmetic by definition — it cannot change a value, so it cannot repaint
fluxon switch(asset) -> morph chart over 500ms { ease: inOutCubic ; stagger: 0.3 }
on click        -> focus(view, at: (bar.i, close), zoom: 2.0, over: 600ms)

A transition's settle value — where it lands — is analysis data and lives in the oracle. Its trajectory — how it gets there — is cosmetic and does not. That is why prefers-reduced-motion can jump straight to the end state and change nothing that any verdict depends on.

#APP — the plane that remembers

Clock events
Shape a bounded Model, a pure update, a pure view, declarative subs
Effects inert command data the host executes, under default-deny capabilities

Post-v1. The APP plane is sealed in design and additive to the core; its rollout follows the v1 language.

The other three planes cannot hold state that persists between events and decides what is displayed. The APP plane adds exactly that, and pays for it with a strict recipe: everything ambient — time, input, randomness, the network, analysis values — arrives as a message, and the message journal is the single source of truth. That is what makes an application replayable, testable without a mock, and re-executable by a server bit-for-bit.

See App plane for the full contract.

#The firewall

One rule holds the whole design together:

Dependency arrows never point toward a weaker guarantee.

Presentation may read analysis. Analysis may never read presentation. The APP plane may read analysis (read-only, through a typed subscription) and may orchestrate presentation (through commands) — but it may never write analysis either.

   APP  (mutable state + effects)          ← the most permissive plane
    │  reads ANALYSIS  (Sub OnSeries)              ✔ read-only
    │  orchestrates CANVAS / TRANSITION  (Cmd)     ✔
    ▼
 CANVAS / TRANSITION  (cosmetic, per frame)       ← reads ANALYSIS ✔
    ▼
 ANALYSIS  (pure, causal, no-repaint)             ← reads nothing above it ✘

What the firewall actually forbids is precise. These are the values that may never flow into analysis:

Forbidden in analysis Why
screen.*, hover, the pointer screen space is not data; it varies per device
now(), the wall clock it is not replayable, and it would make a past value depend on when you looked
unseeded rand, noise non-deterministic ⇒ two engines disagree
live(e) it reads the forming bar — the one thing that can still change

All four raise [ErrFirewall], at compile time, with an explanation rather than a scolding.

Why this is worth a plane split rather than a lint. A discipline you have to remember is a discipline you will forget at 2 a.m. under a deadline. A firewall enforced by the kind system is one you cannot forget: the name is not in scope, and the compiler will not let the value cross. That is what makes it safe to run a stranger's animated, random, interactive scene right next to the number your decision rests on.

#live() — the one exception, and why it is safe

Traders want to see an indicator update within the forming bar. That reading is genuinely useful and genuinely non-causal, so Flux gives it a name, a plane, and a wall:

fluxplot live(ema(close, 20))          // ✓ display — the forming bar included, per frame
alert live(ema(close, 20)) > 100   // ✗ [ErrFirewall] — a decision may not read a forming value
rsi(live(close), 14) > 70          // ✗ [ErrFirewall] — analysis may not consume one either

live(e) re-evaluates the analysis sub-graph of e including the bar in formation, per frame. Its result may flow only into display sinks (plot, mark, fill, color bars, a scene). Any confirmed sink — an alert, an assertion, a value a calculation consumes — is [ErrFirewall].

Note where live sits, because the two placements are not variations on a theme. It wraps the expression whose sub-graph is to be re-evaluated: live(ema(close, 20)) asks for the average including the forming bar, and lands in a display sink. Pushed inward, onto a kernel's argument — ema(live(close), 20) — it stops being a display request and becomes a forming value handed to a calculation, which is the breach itself. The firewall does not care that a plot is waiting at the far end: the analysis kernel already consumed it.

Three consequences, all of which matter:

That is the general shape of every escape hatch in Flux: name it, bound it, wall it, and show the user what it cost.

#Which plane am I on?

You never declare one. The plane is inferred from what you write — that is the point of "write the maths, the machinery follows":

You write The plane
plot, mark, fill, alert, assert, an indicator expression ANALYSIS
a primitive with props, on … -> …, scene{…}, group, repeat CANVAS
morph, focus, replay TRANSITION
an app block APP

And if you try to mix them in a way the firewall forbids, the compiler tells you which value crossed which line, and what to do instead.

#See also

↑ contents

Lexical structure

This page defines how a Flux source text becomes a stream of tokens: the source model, comments, the significant newline, the complete token catalogue (identifiers, the numeric family and its glued suffixes, strings and interpolation, capability references, operators and punctuation), and the keyword model — which words are reserved, where, and why most of them are only reserved contextually. Everything downstream — the grammar, kind inference, the editor — consumes exactly the token stream specified here.

Two properties frame the whole chapter. First, the lexer is total: any input text produces a token stream (malformed input surfaces as precise diagnostics, never as a crash). Second, the lexer is linear and incremental: every context-dependent device below (the significant newline, interpolation, capability references, the numeric munch) is bounded by counters the lexer already maintains, so retokenizing after an edit touches only the edited neighborhood — the property that keeps live preview under its frame budget.

#Source model

A Flux program is a Unicode text. The fixed alphabet of the language itself — identifiers, keywords, operators, punctuation — is ASCII; arbitrary text (any Unicode) lives inside string literals and comments. Between tokens, spaces and tabs are insignificant and may appear in any number; newlines are significant (see TERM below).

One rule shapes many diagnostics on this page: Flux has no juxtaposition. Two adjacent primary expressions with no operator between them never form a term:

fluxx = 1.50 d      // ✗ syntax error — NUMBER then IDENT: two adjacent primaries
y = 1.50d       // decimal(2) — the glued suffix makes this ONE token
d = 2           // `d` alone is an ordinary identifier — a licit binding
z = 1.50 * d    // valid — the detached `d` is just a name here

Because adjacency is never meaningful, the lexer can afford glued suffix tokens (1.50d, 4px) without ambiguity: either the suffix touches the number and the pair is one token, or it does not and the program is ill-formed unless an operator intervenes.

A script may span several .flux source files; the package visibility modifier (see grammar — modules) is scoped to exactly that set of files.

#Comments

Form Token Extent Role
// … LINE to end of line ordinary comment, skipped
/// … DOC to end of line doc-comment, attaches to the next def
flux/// z-score of a series over n bars
def zscore(x, n=20) = (x - sma(x, n)) / stdev(x, n)

plot zscore(close, 20) as z   // an ordinary comment

A doc-comment is lexically a comment (the parser skips it) but it is not thrown away: the documentation pipeline collects the /// block preceding a def and publishes it as the definition's hover documentation and doc-as-data entry (see the editor). There are no block comments; a comment always ends at the newline. Comments are transparent to the significant-newline rules below — a line that ends in a trailing // … comment is classified by the last token before the comment, and a comment-only line neither ends nor continues a statement.

#The significant newline (TERM)

Flux has no mandatory statement terminator. A statement ends at the end of its line — the lexer emits a TERM token at the newline — unless the line is visibly unfinished. Semicolons are optional separators (never required); writing several statements on one line with ; is legal but idiomatic Flux is one statement per line.

A newline does not emit TERM (the statement continues) when any of the three clauses holds:

  1. (a) Open parenthesis or bracket. The ( / [ nesting depth is greater than zero. Inside parentheses and square brackets, newlines are pure whitespace — which is how a long call wraps across lines. Braces do not reset that depth: a brace body written inside a call is still at depth > 0, so its items need an explicit , or ; separator. At depth zero, a newline separates items, which is why a top-level multi-line record or match needs no commas at all:

    fluxdef f(r) = r.a + r.b
    m = { a: 1                      // depth 0 — the newline separates
          b: 2 }
    n = f({ a: 1 ; b: 2 })          // inside a call — the `;` is REQUIRED
    
  2. (b) The line is pending. The last significant token of the line is an operator, an opening delimiter, a separator, or a keyword that grammatically awaits an operand — if, then, else, let, in, not, and, or, as, from, with, over, def, match, variant, record, app, import, type, representation, tool, on, every, when, tween, spawn, burst, emit, rate, set, morph, replay, focus, cross_up, cross_down, pub, private, package, scene. A contextual keyword only counts here in its keyword role: in x = p.on the trailing on is a field name and the statement ends. (One lexical subtlety: a trailing % glued to a digit is a percent literal, not a pending modulo — 50% ends the line.)

  3. (c) The next token is a continuator. The first token of the following line can only extend an expression, never begin a new item.

Clause (c) is not an ad-hoc list. Normative definition: a token is a continuator iff it belongs to no FIRST set of any list item — formally, iff it is outside ⋃ FIRST(item) taken over every repeated-list production of the grammar (statements, view children, variant constructors, representation and tool hooks, match arms, record fields, capability references, app members). The set is computed from the grammar, not maintained by hand, which makes the rule machine-checkable and keeps it in lockstep with the grammar forever.

Illustratively, the continuators are the purely infix or postfix tokens — . (member access), .., @, +, *, /, %, <, >, <=, >=, ==, !=, cross_up, cross_down, and, or, ->, with, ?, ??, ?., :, ,, ;, the closers ) ] }, the variant separator | — and the purely medial keywords then, else, in, as, from.

Three tokens are deliberately not continuators even though they can extend an expression, because they can also begin a new item: - (unary minus), [ (a list literal) and ( (a parenthesized form). A leading . is a continuator only when not followed by a digit — .upper continues, but .5 is a number and begins an item. To continue across a line on one of these ambiguous tokens, end the previous line pending (clause b) instead:

fluxu = bollinger(close, 20)
  .upper                      // `.` continues the postfix chain — clause (c)

total = close
  + open -                    // `+` continues (c); trailing `-` leaves the line pending (b)
  low

cond = if close > open
  then 1
  else 0                      // medial keywords are continuators — clause (c)

variant Wide { A | B
  | C }                       // `|` is a continuator inside the declaration

multi = sma(
  close,
  20)                         // clause (a): inside ( ) newlines are whitespace

m = { a: 1 }
updated = m
  with { a: 2 }               // `with` is a continuator — postfix record update

The clause-(b) idiom for the ambiguous heads: a -⏎ b (line ends on the operator), v[⏎ i] (line ends on the opener). Similarly, over is not a continuator — as a contextual keyword it may begin an item (for example as a property key) — so a multi-line morph … over d breaks after over, which is a pending word under clause (b).

Why this rule exists. A terminator-free surface reads like the notation authors actually sketch, but it must never become whitespace guesswork. The three clauses are decidable from at most one token of lookahead and the counters the lexer already carries, so newline classification is O(1) per line, linear over the file, and incremental under edits. And because the continuator set is defined as the complement of the computed item-head set, there is no hand-curated list to drift out of sync when the grammar grows: adding a statement head automatically removes it from the continuators.

#Token catalogue

The complete token inventory. Each class is detailed in the sections that follow.

Class Tokens Notes
Identifier IDENT [A-Za-z_][A-Za-z0-9_]*, not reserved at its position
Numbers NUMBER integer, decimal, leading-dot, exponent forms
Suffixed literals DUR PCT PX DEC SPAN RATE glued suffixes; SPAN allows one space
Booleans / absence true false na typed literals (signal; na inhabits every kind)
Strings STRING "…" or '…', single line
Interpolation STR_HEAD STR_MID STR_TAIL fragment tokens of "… {e} …"
Capability ref CAPREF namespace:verb, only inside capability lists
Range / arrow .. -> one token each
Comparison < > <= >= == != cross_up cross_down non-associative level
Additive / multiplicative + - · * / % - also unary
The ? family ?? ?. ? maximal munch, in that order
Punctuation @ . , : ; = ( ) [ ] { } ~ | | separates variant constructors only
Comments LINE DOC //, ///
Layout TERM significant newline

#Identifiers

IDENT = [A-Za-z_] [A-Za-z0-9_]*

Identifiers are ASCII: a letter or underscore, then letters, digits and underscores. An identifier is a name for a binding, parameter, field, kind, argument label, module or declaration. The lone underscore _ is lexically an ordinary identifier with two blessed roles given to it by the grammar and the elaborator: the wildcard pattern in match arms, and the implicit single-parameter placeholder in expression position (vec.map(_ * 1.1), see operators).

Whether a given identifier is available depends on the keyword model below — most keywords in Flux are contextual, so words like render, view or color remain usable as field names, parameters and kind names.

#Numbers

NUMBER = ( digits "." digits | "." digits | digits ) [ ("e"|"E") ["+"|"-"] digits ]

42, 2.5, .5 and 1.5e3 are all NUMBER tokens. A bare NUMBER has kind lit — the const-folded literal that is dimension-polymorphic (close + 10 is a price; see kinds).

Maximal munch, bounded by the dot rule. The number scanner never consumes a . that is followed by another . or by a non-digit. This single rule makes ranges and member access compose with numbers without separators:

fluxlen   = input(14, 2..200)      // NUMBER RANGE NUMBER — the dot is never eaten before `..`
half  = .5                     // a leading-dot NUMBER
band  = (2.5..3.5)             // NUMBER RANGE NUMBER — a range lives in its own slots
u     = bollinger(close, 20).upper   // `.upper` is member access, not a malformed number
sci   = 1.                  // exponent form
neg   = -0.5                   // unary minus applied to NUMBER (the sign is not part of the token)

#Suffixed literals

Six literal classes carry their unit as a suffix glued to the number (no space); SPAN alone also accepts exactly one space. Each token is typed at the expression position by rule [LitTyped] of the kind system:

Token Form Examples Kind
DUR NUMBER s | ms 2s, 300ms, 1.5s duration
PCT NUMBER % 50%, 0.5% ratio
PX NUMBER px 4px, 12px num tagged px (screen space)
DEC NUMBER d 1.50d, 1.5e3d decimal(scale)
SPAN NUMBER [ ] bar | bars 200 bars, 1 bars, 3bar barspan
RATE NUMBER /s | /min 40/s, 3/min num·T⁻¹ (per-time rate)
fluxb  = 300s + 20ms               // duration arithmetic
c  = 50%                       // ratio
d  = 4px                       // screen-space size (CANVAS styling)
r  = 40/s                      // emission rate
sp = life(200 bars)            // barspan — unifies every(n bars) and life: n bars
a  = 1.50d                     // decimal, scale 2 — exact money arithmetic

The glued-d rule (representative of the whole family). The d suffix must touch the number, and the character after the suffix must not extend an identifier:

fluxa = 1.50d       // DEC(1.50, scale 2) — one token
b = 1.      // DEC — the suffix composes with scientific notation
c = 1.50 d      // ✗ syntax error — NUMBER IDENT, two adjacent primaries (no juxtaposition)
d = 2           // a detached `d` is an ordinary identifier — a licit binding
d2 = 1.50 * d   // valid — 1.50 times the bound `d`

The same boundary check protects every suffix: 2se is not a DUR (the trailing e would extend an identifier, so the lexer yields NUMBER(2) then IDENT(se), which the parser rejects as juxtaposition), and 40/sec is not a RATE (it is 40 / sec, a division by the identifier sec). The DEC scale is the number of written fraction digits: 1.50d is decimal(2), 2d is decimal(0).

Why glued suffixes. Units on literals could have been separate tokens (1.50 d) or constructor calls (dur(300)), but both spellings put a parse between the number and its unit, and the detached word would collide with ordinary identifiers — d, s and bars are all reasonable binding names. Gluing makes the unit part of the token, so the decision is made by the lexer with zero grammar impact: 1.50d can never be misread, d alone is never stolen from the author, and each literal arrives at inference already carrying its kind. The one relaxation — 200 bars with a single space — is accepted because bars is a reserved head there and the span form reads as prose in every(…) and life: positions.

Open decision. The plan keeps SPAN, PX, RATE and the binary % operator in v1 and flags them for final ratification; they are documented here as kept.

#Strings

STRING = '"' frag '"' | "'" frag "'"

Both delimiters are equivalent — Flux has no character type, so the single quote is free to be a string delimiter. A string literal has kind string and must close on the line it opened (a raw newline inside a string is a syntax error). Inside a string, a backslash makes the next character literal: \" inside a "…" string, \{ and \} for literal braces (see interpolation below).

fluxplain  = "no interpolation"
single = 'quote style'
alert close cross_up open "crossed"       // strings feed the text channels (labels, messages)

Strings are values of the categorical kind string — bounded, immutable text for labels, prompts and messages. They are never plotted as a series, and + on two strings concatenates (the one categorical overload of +; see operators).

#String interpolation

A { inside a string opens an interpolation hole holding a full Flux expression. Lexically the literal is split into fragment tokens:

STR_HEAD = '"' frag '{'          (opening fragment — also with ' delimiter)
STR_MID  = '}' frag '{'          (middle fragment)
STR_TAIL = '}' frag '"'          (closing fragment)
interpStr = STR_HEAD expr { STR_MID expr } STR_TAIL

A string containing no unescaped { stays one atomic STRING token. \{ and \} denote literal braces inside a fragment. The interpolation tokenizer matches its closing delimiter to the opening one ("…" or '…'), and the { of a fragment opens a lexer mode that is bounded by the brace counter the lexer already keeps — so holes nest to any depth the program itself can nest, and tokenization stays linear and incremental.

fluxm = { a: 0 }
x = close
mark close > open "close {close} above {str(open)}"
label  = "nested {(m with { a: 1 }).a} brace"    // a full expression, inner braces counted
single = 'quote {x} style'                 // interpolation works in '…' too
lit    = "a literal \{brace\}"             // escaped — no hole opened

An interpolated literal has kind string. Its AST is a concatenation of the fragments with each hole's expression formatted by the canonical formatter for its kind (fmt.* — pinned, identical across every execution target), so a mark or alert label can be dynamic without any formatting boilerplate. See text for the formatting rules.

#Capability references (CAPREF)

CAPREF = IDENT ":" IDENT        (only inside a capability list)

An APP-plane descriptor declares what it may touch as a list of capability references — namespace:verb pairs like chart:read, storage:own, levels:write, or a bare IDENT for single-token capabilities (sfx):

fluxapp structureGame {
  capabilities: [chart:read, storage:own, levels:write, sfx, app:launch]
  // …
}

CAPREF is deliberately not a greedy lexer rule. It is recognized only in one grammatical state — element of a capability list — so its : never competes with the other colons of the language (record fields f: v, properties at: (x, y), when …: children, the ternary's :). Everywhere else, a:b is an identifier, a colon and an identifier with their usual meanings.

Inside that one state the lexer reads the first segment literally, even when it spells a reserved word: app:launch is a legal capability reference although app is a hard keyword everywhere else. The relaxation is scoped to exactly the namespace position of a capability list; it does not extend to any other position in the language.

#Operators and punctuation

Tokens Role
.. range — 2..200, fill a..b, lo..hi properties; never an arithmetic operator
-> THE arrow — one token, one grammar production, five contextual readings (grammar)
< > <= >= == != cross_up cross_down comparisons (one non-associative level)
+ - · * / % additive / multiplicative; - is also the unary minus
?? ?. ? null-coalescing · safe navigation · ternary head
@ clock suffix — close@"1d", sma(close, 9)@tf("4h")
. member access / UFCS call (and ..'s shorter sibling)
= : , ; binding, key/value and label colon, separators
( ) [ ] { } grouping and call · index and list · blocks, records, bodies
~ approximate-cadence marker in every(~ d) (CANVAS; see canvas)
| separator of variant constructors — its only role

Three lexical facts here are load-bearing:

Why a single arrow token. Arrow-like syntax appears in five places (lambdas, event wiring, tween pairs, match arms, comprehensions), and languages that grow separately spelled arrows for those roles force readers to memorize which arrow belongs where. Flux decrees one symbol: -> is one token and one grammar production, and the five readings are selected by the context — the guarding head (on, match, for … in) or, for the unguarded uses, by kind inference. The lexer's contribution to that decree is minimal and strict: exactly one arrow spelling exists. The full disambiguation story is on the grammar page.

#The keyword model

Flux keeps the hard keyword set as small as the grammar allows, in three tiers plus a deliberate non-tier.

#Tier 0 — hard-reserved everywhere

The core binders, connectors, operators and literals are reserved at every position:

def  let  in  if  then  else  for  match  with  variant  record  app
and  or  not  cross_up  cross_down  true  false  na

plus the two purely medial connectors as and from. These words can never be an identifier — they are binders and separators whose contextual release would buy nothing and cost lookahead. The single scoped exception, described above, is the namespace position of a capability reference, where app:launch reads app literally.

#Tier 1 — contextual heads

Every other keyword of the language is contextual: it is a keyword at its head position (the first token of its production, or a medial connector like over in morph … over d) and an ordinary IDENT in the five binding slots:

# Binding slot Example with a Tier-1 word
a field name (declaration or literal) record Stop { color: color }
b parameter name def zone(rect) = rect.w * rect.h
c member access (.name) gpu.dot, x = p.on
d kind name c: color, state: variant { … }
e argument label focus(view, over: 600ms, pad: 5%)

The Tier-1 words, by family:

The visibility modifiers pub, private and package form a distinct contextual subclass: they are not production heads but declaration prefixes, decided by one token of lookahead (followed by a declaration head or a binding, they are modifiers; followed by =, :, . or (, they are plain identifiers).

A Tier-1 word is a keyword only where the parser can shift it as one — at its head or medial position. Everywhere else the lexer hands back an ordinary identifier, so def f(render) = render * 2 is legal, and so is the binding render = 3: a bind's left-hand side shifts an identifier, not a head. What you cannot do is use the word where its production expects it and mean something else.

fluxrecord Stop { color: color }               // (a) field name + (d) kind name — both `color`
tool fib(a, b) {
  render: line { x1: a.bar; y1: a.price; x2: b.bar; y2: b.price }
}                                          // `render` is a keyword here — hook-head position
focus(view, over: 600ms)                   // (e) `over` as an argument label

Why contextual reservation. Reserving every head outright would force authors into distorted names — focusRef, bounds, vdot — precisely where focus, rect and dot are the intended vocabulary of the domain. Contextual reservation keeps the readability that keyword-headed statements buy (every statement is committed by its first token) while returning the words to authors in the five slots where no head can ever occur. The two sides are provably disjoint: the binding slots sit in parser states where only an IDENT is expected, so freeing a word there introduces no ambiguity anywhere — a claim the grammar build re-verifies mechanically on every change.

#Reserved ahead of need

Flux reserves a word before shipping its production whenever the word is destined for the surface, so that no existing program can shadow it in the meantime (see additivity). The APP-plane words (app, match, capabilities, init, update, subs, contributes, view, emit, variant) and the module words (import, pub, private, package) were reserved this way from the first version and have since received their productions.

Reserved. test is the current instance: it is reserved in v1 with no production. The test "name" { … } block is a tooling convenience whose production ships later, additively, together with its golden tests.

#Deliberately NOT reserved — built-ins

The built-in value names are ordinary identifiers, not keywords:

close  open  high  low  volume  time  hl2  hlc3  ohlc4
bar  clock  screen  pane  ratio  depth  z  up  down  self  range

A program may bind them — and a style lint immediately flags the shadowing:

fluxclose = 42            // legal — the shadowing lint flags: `close` hides the built-in series
plot close            // now plots 42 at every bar

Why not reserve them. These names are vocabulary, not structure. Reserving open, range or z would poison huge swaths of ordinary naming (every rectangle has a range, every record an open something), for zero parsing benefit — no grammar production is anchored on them. A lint gives the author the warning that matters (accidental shadowing of a data source) while keeping the keyword set minimal, which in turn keeps the additivity story clean: the fewer words the language owns, the fewer future collisions it must manage.

#Lexical errors

Because both lexer and parser are total, malformed input yields diagnostics, never crashes. The characteristic lexical-level rejections:

fluxs = "unterminated          // ✗ syntax error — a string must close on its own line
x = 1.50 d                 // ✗ syntax error — juxtaposition (NUMBER then IDENT)
t =                     // ✗ syntax error — `se` does not complete a duration suffix

Everything else that looks lexical — a < b < c, a stray ->, a { a, b } in expression position — is rejected one stage later, by the grammar; those diagnostics are catalogued on the grammar page.

#See also

↑ contents

Grammar

This page is the normative syntax of Flux: every statement form, the full expression grammar, the single arrow -> and its five contextual readings, the precedence ladder, the disambiguation decisions that keep parsing deterministic, and the six machine-verified formal properties the grammar is frozen against. Tokens (IDENT, NUMBER, STRING, TERM, …) are defined in lexical structure; what programs mean is the business of kinds, inference and time and state.

There is exactly one grammar, and it is stated once: the normative EBNF below is expressed as a Lezer LR grammar — the same artifact drives the compiler, the editor and the documentation tooling, so no second, drifting description of the syntax exists. The grammar build accepting with zero unresolved conflicts is the machine check that the language is unambiguous (see formal properties).

Notation. { x } repeats zero or more times, [ x ] is optional, | separates alternatives, "x" is a literal keyword or punctuation token, and UPPERCASE names are lexical tokens from the token catalogue.

#One grammar, all planes

Flux programs live on four planes — ANALYSIS, CANVAS, TRANSITION and APP — but the syntax is a single grammar. The plane is inferred from the constructs used, never annotated: there is no plane pragma, no file-level mode switch. plot and alert are ANALYSIS sinks; on, the shape primitives and spawn are CANVAS; morph, focus and replay are TRANSITION; an app descriptor is APP. A single file freely mixes them — an indicator and its presentation are one program — and the firewall between planes (presentation may read analysis, never the reverse) is enforced by the dependency analysis on the parsed tree, not by the grammar. See the four planes.

fluxplot close                                   // ANALYSIS — the smallest program
on click -> burst(40) dot { vel: 3 }         // CANVAS — same file, same grammar
morph chart over 600ms                       // TRANSITION

#Programs and statement separation

program  = { TERM } [ stmt { TERMSEP stmt } { TERM } ]
TERMSEP  = ( TERM | ";" ) { TERM | ";" }
sep      = ( TERM | "," | ";" ) { TERM | "," | ";" }

A program is a sequence of statements separated by significant newlines (TERM) and/or optional semicolons; blank lines are free. Inside brace bodies, list items are separated by sep — a newline, comma or semicolon, interchangeably — which is why a multi-line record at the top level needs no trailing commas and a one-line block can use ;. The newline only counts as a separator at parenthesis depth zero: a brace body written inside a call is still inside the call's parentheses, so its items need an explicit , or ;. The complete newline policy (when a newline terminates and when it continues) is specified in lexical structure.

Flux has no expression-statements: a bare expression at statement level is a syntax error, which is what makes newline-terminated statements unambiguous.

fluxrsi(close, 14)        // ✗ syntax error — an expression is not a statement; write `plot rsi(close, 14)`

#Statement forms

stmt = declStmt | plotStmt | markStmt | fillStmt | colorBarsStmt | alertStmt | assertStmt
     | onStmt | groupStmt | repeatStmt | forStmt | uiElement | primStmt | spawnStmt
     | tweenStmt | effectStmt | setStmt | morphStmt | focusStmt | replayStmt
     | appStmt | importStmt

Nearly every statement is committed by its first token — the keyword-head idiom: at statement level, only assertStmt can begin with assert, only variantDecl with variant, and so on. The parser never guesses; each head owns a distinct LR state. The two statements that begin with an IDENT (a binding, a view container) and the one that begins with { (a destructuring binding) are resolved by one token of lookahead, catalogued below.

#Declarations

declStmt = [ visMod ] ( bindStmt | defStmt | typeDecl | variantDecl | recordDecl
                      | reprStmt | toolStmt )
visMod   = "pub" | "private" | "package"
bindStmt = letPat "=" expr
letPat   = recordPat | IDENT
defStmt  = [ DOC ] "def" IDENT "(" [ params ] ")" "=" expr
params   = param { "," param }
param    = IDENT [ "=" literal ]

A binding names a value for the rest of the program; its left-hand side is a single identifier or an irrefutable record pattern that destructures on the spot. Bindings are immutable — there is no assignment statement, and let exists only inside expressions.

fluxn = input(14, 2..200)
{upper, lower} = bollinger(close, 20)        // destructuring bind — no `:` after `{ IDENT`
let n = 20                                   // ✗ syntax error — `let` is expression-only; top-level binds are bare

A function definition binds a name to a parameterized expression. Parameters may carry literal defaults; a /// doc-comment attaches to the definition. Recursion is rejected ([ErrTotalRec]) — totality by construction.

flux/// z-score of a series over n bars
def zscore(x, n=20) = (x - sma(x, n)) / stdev(x, n)
plot zscore(close) as z

The optional visibility modifier scopes a declaration for the module system: pub crosses an import, package spans the source files of one script, private (the default) stays in its file. The modifier is contextual — decided by the token after it — so pub, private and package remain usable as ordinary names in binding slots.

fluxpub def helper(x) = x * 2
private record Internal { a: num }
pub {upper, lower} = bollinger(close, 20)    // a modifier also prefixes a (destructuring) bind

#Named types — variant, record, alias

variantDecl = "variant" IDENT "{" { TERM } ctorDecl { "|" ctorDecl } { sep } "}"
ctorDecl    = IDENT [ "(" [ field { "," field } ] ")" ]
field       = [ IDENT ":" ] kindExpr
recordDecl  = "record" IDENT "{" { TERM } fieldDecl { sep fieldDecl } { sep } "}"
fieldDecl   = IDENT ":" kindExpr [ "=" ( literal | "na" ) ]
typeDecl    = "type" IDENT [ "(" IDENT { "," IDENT } ")" ] "=" kindExpr
kindExpr    = "vec" "(" kindExpr "," constLen ")"
            | "variant" "{" { TERM } ctorDecl { "|" ctorDecl } { sep } "}"
            | "record" "{" { TERM } fieldDecl { sep fieldDecl } { sep } "}"
            | IDENT [ "(" [ argList ] ")" ]
constLen    = NUMBER | IDENT

variant declares a named sum type (constructors separated by |, payloads positional with optional documentary field names); record declares a named product type (every field named and kinded, with optional constant defaults); type declares a transparent alias, possibly parameterized — substitution, not a new type. A kindExpr names a kind of the lattice (price, osc(0,100)), a declared type, a vec(element, constLength), or an inline structural variant{…} / record{…}. Angle brackets are never type delimiters — < and > are exclusively comparison operators — so every parameterized form uses parentheses.

fluxvariant Phase { ask | suspense | revealed }
variant Tool  { Select | Event(kind: num) | At(price) }   // named or anonymous payloads
record Level  { price: price; kind: num = 3; label: string }
record Inline {
  state: variant { Connecting | Ready }                   // inline structural kinds
  slots: vec(Level, 100)
}
type long = decimal(18, 0)
type Series(T) = vec(T, 500)                              // transparent, parameterized alias

The reference graph between named types must be acyclic — record Node { next: Node } is rejected with [ErrTotalType] at name resolution, keeping every type finite.

#ANALYSIS sinks and parameters

plotStmt      = "plot" exprList [ "as" IDENT ] [ block ]
markStmt      = "mark" condExpr [ strLit ] [ block ]
fillStmt      = "fill" addExpr ".." addExpr [ block ]
colorBarsStmt = "color" "bars" ":" condExpr
alertStmt     = "alert" condExpr [ strLit ]
assertStmt    = "assert" condExpr [ strLit ] [ "at" addExpr ]
strLit        = STRING | interpStr
exprList      = expr { "," expr }

The sinks publish analysis values to the host: plot traces series (optionally named with as, optionally styled with a trailing block), mark drops labeled markers on a condition, fill shades between two series, color bars: colors the bars themselves, alert raises a message on a condition, and assert states an invariant that must hold on every bar — or, with at, on one given bar. An assertion whose condition is na (warm-up) passes; it fires only on a definitely-false signal.

fluxbb     = bollinger(close, 20)
equity = cum(close - open)
fill bb.upper..bb.lower
color bars: if close > ema(close, 200)@"1d" then up else down
alert close cross_up open "crossed"
assert rsi(close, 14) <= 100 "rsi bounded"    // na on bars 0–13 — vacuously satisfied
assert equity > 0 "positive" at 500
mark rsi(close, 14) > 70 "overbought" { size: 8 }

A script parameter is the expression form input(…) (a primary, usable anywhere an expression is):

inputExpr    = "input" "(" inputDefault { "," inputArg } ")"
inputDefault = literal | IDENT | listLit
inputArg     = metaArg | inputExtra
metaArg      = IDENT ":" strLit
inputExtra   = addExpr [ ".." addExpr ]

The default must be constant — a literal, a source identifier, or a list of string labels (the enumerated form); its kind fixes the parameter's kind. After the default come an optional range (2..200) or bound and named meta-UI arguments (title:, group:, tooltip:, inline:), distinguished from a range by the IDENT : lookahead:

fluxlen  = input(14, 2..200)
src  = input(close)
name = input("fast", title: "Label", group: "Style")
mode = input(["ema", "sma"], tooltip: "kind")     // enumerated — a variant of the labels
dec  = input(1.50d)                                // decimal parameter

#CANVAS statements

onStmt      = "on" eventExpr ARROW action
eventExpr   = "hover" | "click" | "drag" | "enter" | "exit" | "move" | "wheel"
            | everyExpr | condExpr
everyExpr   = "every" "(" [ "~" ] ( DUR | SPAN | addExpr ) ")"
action      = stmt | block
groupStmt   = "group" [ block ]
repeatStmt  = "repeat" addExpr "as" IDENT [ block ]
forStmt     = "for" IDENT "in" condExpr ARROW uiChild
primStmt    = primitive [ "when" condExpr ]
primitive   = shapePrim [ block ] | contentPrim [ condExpr ] [ block ]
shapePrim   = "dot" | "circle" | "ring" | "rect" | "square" | "triangle" | "poly"
            | "line" | "path" | "backdrop" | "column"
contentPrim = "text" | "image" | "svg" | "sparkline"
spawnStmt   = ( "spawn" | "burst" "(" addExpr ")" | "emit" "rate" "(" addExpr ")" ) primitive
tweenStmt   = "tween" propPath arrowPair [ overClause ]
setStmt     = "set" propPath "=" expr
effectStmt  = ( "flash" | "bounce" | "pulse" | "shake" ) [ postfix ] [ block ]
propPath    = IDENT { "." IDENT }

on wires an event — an interaction verb, a cadence every(…), or any boolean stream — to an action. A primitive draws one element, with a property block and an optional trailing when guard. spawn / burst(n) / emit rate(r) create pooled short-lived elements; tween, set and the effect words animate properties. The semantics of all of these live on the CANVAS page; grammatically they are keyword-headed statements sharing the one block form.

fluxon click -> group { dot { at:(bar.i, close); r: 4 } }
on every(1 bars) -> spawn ring { r: 6->24; life: 200 bars }
on every(~ 500ms) -> pulse                       // `~` — approximate cadence
on close > highest(close, 250)[1] -> burst(40) dot { vel: 3 }
emit rate(norm(volume) * 40) dot { size: 2 }
tween p.r 6->24 over 300ms
circle { at:(spring(close)); trail: 24 } when close > open

View containers and comprehension. A view container is an identifier head (optionally called) followed by a mandatory brace body of children; the comprehension for … in … -> is a statement/child form only — for never begins an expression (pure data mapping is vec.map):

uiElement = IDENT [ callTail ] uiBlock
uiBlock   = "{" { TERM } [ uiChild { sep uiChild } { sep } ] "}"
uiChild   = uiElement | forStmt | whenChild | primStmt | expr
whenChild = "when" condExpr ":" uiChild
fluxslots = window(close, 5)
row { text "a"; text "b" }
panel(slot: side) { col { for s in slots -> renderSlot(s) } }
grid(cols: 2) { when close > open: text "up"; col { text "deep" } }

A childless container (button(reset), panel(slot: x)) is not a uiElement — it is an ordinary call expression of kind ui; the brace body is what makes the container form.

#TRANSITION statements

morphStmt  = "morph" ( "chart" | IDENT ) [ overClause ] [ block ]
overClause = "over" condExpr
focusStmt  = "focus" "(" "view" { "," arg } ")"
replayStmt = "replay" "from" condExpr overClause
fluxanchors = window(close, 10)
morph chart over 600ms
morph pnf { keep: anchors }
focus(view, over: 600ms, pad: 5%)     // `over` here is an argument label, not the clause keyword
replay from close > 100 over 2s

#Representations and drawing tools

reprStmt  = "representation" IDENT "(" [ params ] ")" reprBlock
reprBlock = "{" { TERM } [ reprHook { sep reprHook } { sep } ] "}"
reprHook  = reprKey ":" reprVal
reprKey   = "transform" | "render" | "reduce" | "liveReduce" | "updateLastUnit" | "persistKey"
reprVal   = primStmt | block | expr
toolStmt  = "tool" IDENT "(" [ params ] ")" toolBlock
toolBlock = "{" { TERM } [ toolHook { sep toolHook } { sep } ] "}"
toolHook  = toolKey ":" toolVal
toolKey   = "barExtent" | "priceExtent" | "render"
toolVal   = primStmt | block | expr

Both are keyword-headed declaration forms whose bodies are a closed enumeration of hooks — the script authors the pure functions (geometry, rendering, reduction), the host supplies everything else (placement, hit-testing, persistence; hence hitTest and lod are not keywords and remain free names). See host integration.

fluxrepresentation pnf(box, rev) {
  transform: rebin(close, box, rev)
  render: column { w: 1 }
  reduce: decimate(cols, k)
  liveReduce: mutateHead(cols)
  updateLastUnit: patch(cols)
  persistKey: "pnf-v1"
}

tool fib(a, b) {
  barExtent: (a.bar, b.bar)
  priceExtent: (a.price, b.price)
  render: line { x1: a.bar; y1: a.price; x2: b.bar; y2: b.price }
}

#The APP descriptor

appStmt    = "app" IDENT appBody
appBody    = "{" { TERM } [ appMember { sep appMember } { sep } ] "}"
appMember  = capEntry | memberDef
capEntry   = "capabilities" ":" capList
capList    = "[" { TERM } [ capRef { "," capRef } { sep } ] "]"
capRef     = CAPREF | IDENT
memberDef  = ( "init" | "update" | "view" | "subs" | "contributes" )
             "(" [ params ] ")" "=" memberBody
memberBody = uiElement | expr

An app is a named descriptor with one capabilities: entry and the five fixed members of the TEA harness (TEA — The Elm Architecture). The member heads are keywords, not identifiers — the form is deliberately def-less because these five roles are fixed. memberBody is the one place in the grammar where a statement-level view container is the right-hand side of = (a view returns a view); everywhere else the RHS of = is an expression.

fluxapp structureGame {
  capabilities: [chart:read, storage:own, levels:write, sfx]
  init(p) = {score: 0, phase: ask}
  update(m, msg) = match msg {
    Tick(dt) -> m with {score: m.score + dt}
    _ -> m
  }
  view(m) = panel(slot: side) {
    row { text "score {m.score}"; when m.done: button(reset) }
    for t in TOOLS -> button(t)
  }
  subs(m) = [OnTick(Tick)]
}

The APP plane's semantics — Model, Msg, commands as inert data, subscriptions — are specified on the APP plane page.

#Imports and visibility

importStmt = "import" pkgRef [ "as" IDENT ]
pkgRef     = IDENT "/" IDENT

import author/package binds a package under its name or an as alias; only the package's pub declarations are reachable, as qualified names mod.f. The / of the coordinate is never confused with division — it is only reachable after import IDENT, a state where no expression exists.

fluximport acme/wyckoff as wk
pub def helper(x) = wk.zone(x) * 2

Post-v1. The import mechanism, content-addressed resolution and lockfile semantics are fully specified (packages); the public registry deployment is deferred.

#Expressions

The expression grammar is a strict stratification: each level refers only to the next tighter level, so precedence is built into the shape of the grammar itself (no precedence declarations are needed for the arithmetic chain, and the parse is LR(1) by construction).

expr      = arrowExpr
arrowExpr = condExpr [ ARROW expr ]                (right-assoc; lambda or pair — see below)
condExpr  = ifExpr | letExpr | ternExpr
ifExpr    = "if" expr "then" expr "else" expr      (else mandatory)
letExpr   = "let" letPat "=" expr "in" expr
ternExpr  = coalExpr [ "?" expr ":" expr ]         (≡ if/then/else)
coalExpr  = orExpr [ "??" coalExpr ]               (right-assoc; ≡ nz)
orExpr    = andExpr { "or" andExpr }
andExpr   = notExpr { "and" notExpr }
notExpr   = "not" notExpr | cmpExpr
cmpExpr   = addExpr [ cmpOp addExpr ]              (NON-associative)
addExpr   = mulExpr { addOp mulExpr }
mulExpr   = unary { mulOp unary }
unary     = "-" unary | postfix
postfix   = primary { callTail | indexTail | clockTail | memberTail | safeNavTail | withTail }
primary   = NUMBER | DEC | DUR | PCT | PX | SPAN | RATE | STRING | interpStr | BOOL | "na"
          | IDENT | inputExpr | tweenSig | matchExpr | listLit | recordLit | blockExpr
          | sceneExpr | parenForm
tweenSig  = "tween" "(" arrowPair [ "," argList ] ")"
arrowPair = condExpr ARROW condExpr

Railroad view of the expression spine, from expr down to primary
Figure — the stratified expression spine: each level calls only the next tighter one.

#Conditionals: if, let, ternary, coalescing

if always carries an else — there is no dangling-else problem because the incomplete form does not exist. let … in scopes a binding (or an irrefutable destructuring) over one body expression. The ternary is the same tree as if/then/else, and ?? is sugar for nz (replace na by a default):

fluxplot if close > open then 1 else 0
r = let x = close - open in x * x
d = let { upper, lower } = bollinger(close, 20) in upper - lower   // destructuring let
t = close > open ? 1 : 0                     // ≡ if close > open then 1 else 0
z = close[1] ?? 0                            // ≡ nz(close[1], 0)
x = if a then 1                              // ✗ syntax error — else is mandatory

#match

matchExpr = "match" condExpr "{" { TERM } matchArm { sep matchArm } { sep } "}"
matchArm  = pattern ARROW expr
pattern   = "_" | "na" | ctorPat | recordPat | IDENT
ctorPat   = IDENT [ "(" [ IDENT { "," IDENT } ] ")" ]
recordPat = "{" [ IDENT { "," IDENT } ] "}"

match is the eliminator of variant values (and of na), usable in any expression position. The scrutinee is arrow-free (condExpr), so every -> inside the braces belongs to an arm. Patterns are flat in v1: wildcard, na, a constructor with bound payload names, a record destructure, or a binding identifier — no deep nesting. Exhaustiveness is checked statically; a non-exhaustive match is rejected with [ErrTotalMatch].

fluxvariant Phase { ask | suspense | revealed }
variant Tool  { Select | Event(kind: num) | At(price) }
m = { phase: ask }
t = Event(3)
next = match m.phase {
  ask -> suspense
  suspense -> revealed
  _ -> ask
}
which = match t { Event(k) -> k; At(p) -> 0; _ -> na }
q = SaveState.Saved            // a qualified constructor disambiguates cross-variant homonyms

A nullary tag and a binding identifier are the same parse tree (an IDENT pattern); whether ask names a known constructor or binds a fresh variable is resolved semantically, exactly like function resolution — never a parse fork.

#Record, list and block literals; scene

recordLit = "{" { TERM } fieldAssign { sep fieldAssign } { sep } "}"     (≥ 1 field)
fieldAssign = IDENT ":" expr
listLit   = "[" { TERM } [ expr { "," expr } { sep } ] "]"
blockExpr = "{" { TERM } { blockBind sep } expr { TERM } "}"
blockBind = IDENT "=" expr
sceneExpr = "scene" uiBlock
parenForm = "(" [ parenItem { "," parenItem } ] ")"
parenItem = expr [ ".." expr ]

A record literal builds the first record ({a: 1, b: 2}; an empty {} is not a record). A list literal builds a bounded vec ([1, 2, 3], [] — the spine of every APP command list). A block expression sequences immutable bindings before a final expression and desugars into nested let … in — the multi-line body form:

fluxm = {a: 1, b: 2}
xs = [1, 2, 3]
empty = []
v = { x = 1; y = x * 2; y + x }      // blockExpr — desugars to let x = 1 in let y = … in y + x
w = { close }                        // a one-expression block
bad = { a, b }                       // ✗ syntax error — neither a record (no `:`) nor a block (no `=`)

scene { … } packages a multi-element CANVAS scene as a value of kind ui — the only expression form of a scene, which otherwise lives at statement level. It is how a def returns an overlay:

fluxdef overlayOf(d) = scene {
  line { a: d.a; b: d.b }
  for it in items(d) -> dot { at:(it.bar, it.price) }
}

The paren form unifies grouping, coordinates and lambda heads: (x) grouping, (x, y) a coordinate/argument pair (at:(bar.i, close)), (2..200) a range operand, (p) -> … a lambda head.

#The postfix chain

callTail    = "(" [ argList ] ")"
indexTail   = "[" expr "]"
memberTail  = "." IDENT
safeNavTail = "?." IDENT
clockTail   = "@" clockOperand
clockOperand = STRING | IDENT [ callTail ] | "(" expr ")"
withTail    = "with" recordUpdateBody
recordUpdateBody = "{" { TERM } [ fieldAssign { sep fieldAssign } { sep } ] "}"
argList     = arg { "," arg }
arg         = [ IDENT ":" ] expr
arrowPair   = condExpr ARROW condExpr

All six suffixes bind at the same (tightest) level and associate left, in lexical order:

fluxslots = window(close, 20)
bb    = bollinger(close, 20)
m     = { a: 1 }
i     = input(0, 0..19)
chain = bollinger(close, 20).upper[1]@"1d"
//      ((( bollinger(close,20) ).upper )[1] )@"1d"
prev  = close[1]                       // [Delay] — scalar stream, constant index
s     = slots[i]                       // [Index] — vec element, runtime index, out-of-bounds → na
htf   = ema(close, 50)@"1d"            // clock suffix — see time-and-state
safe  = bb?.upper                      // na-propagating navigation
y     = m with {a: 3}                  // functional record update — shape-preserving

The @ operand is deliberately restricted (a string, an identifier or call, or a parenthesized expression) so that x@"1d" + 1 parses as (x@"1d") + 1 with no precedence subtleties. with { … } is a postfix keyword, not a brace-led expression — the update body is only reachable after the word with, which is what keeps it distinct from every other brace.

Arguments may be labeled (focus(view, over: 600ms)); the label is decided by the IDENT : lookahead. Member access doubles as UFCS — close.ema(20).rsi(14) is rsi(ema(close, 20), 14); the . resolves to field, function call or qualified module name at compilation with no grammar impact (see Operators).

#Lambdas

A lambda is an arrow whose left side is a paren form of bare identifiers, in a position that expects a function (the higher-order arguments of fold, map, scan, loop, …):

fluxdef ema0(s, n) = let a = 2/(n+1) in scan(s, (p) -> a*s + (1-a)*p)

r   = window(close / close[1], 20)   // a vector of ratios
sq  = vec.map(r, (x) -> x * x)
inc = vec.map(r, _ + 1)              // placeholder sugar — exactly one `_`, mono-argument

The parens are part of the form — a bare x -> … in a value position is a tween pair, not a lambda (see the next section); the single-argument shorthand is the _ placeholder. A multi-statement body is a block expression: (x) -> { d = x - open; d * d }.

#Blocks and properties

block     = "{" { TERM } [ item { sep item } { sep } ] "}"
item      = propEntry | stmt
propEntry = IDENT [ ":" propValue ]
propValue = arrowPair | addExpr ".." addExpr | condExpr
literal   = NUMBER | DEC | DUR | PCT | PX | SPAN | STRING | BOOL

There is one block form. Its items are properties (key: value, or a bare flag like stagger) and/or statements; which of the two a given head permits (a dot block takes properties, a group block takes statements) is a semantic restriction, not a separate grammar. A property value may be a plain value, a range lo..hi, or a tween pair a->b:

fluxdot { at:(bar.i, close); r: 6->24; glow: 16; span: 2..8; stagger }

#The single arrow

-> is one token and one grammar productionArrow { lhs, rhs } — with five readings selected entirely by context. There is no second arrow symbol anywhere in the language.

The five contextual readings of the single arrow token
Figure — one arrow token, five readings, each selected by its guarding context.

# Reading Example What selects it
1 lambda scan(s, (p) -> a*s + (1-a)*p) left side is a paren form of bare identifiers, in a position expecting a function
2 event → action on click -> pulse the on head; the event operand is arrow-free
3 tween pair r: 6->24, tween(0->1, ease: out) a value position (property, tween); both sides are values
4 match arm Event(k) -> k the match head owns the braces; every arm arrow is claimed by it
5 view comprehension for t in TOOLS -> button(t) the for … in head; the collection is arrow-free

Deterministic by construction, in two steps:

fluxplot -> 3            // ✗ syntax error — an arrow needs a left side; no production begins with ->

Why one arrow. Five separately spelled arrows would demand five tokens, five precedence entries and a reader's mental table mapping spelling to role. One token with head-guarded readings costs the grammar nothing (each guard is an LR state the head already owns), keeps every program visually consistent, and moves the only genuine ambiguity — lambda versus pair — to the kind checker, which must inspect that position anyway. The parser never forks.

#Precedence and associativity

From loosest to tightest; each level is a stratum of the grammar above:

Level Operators Associativity
0 -> (lambda / pair) right
1 ? : ternary · if/then/else · let/in right; else mandatory
2 ?? right
3 or left
4 and left
5 not prefix
6 < > <= >= == != cross_up cross_down non-associative
7 + - left
8 * / % left
9 unary - prefix — tighter than *//
10 postfix f(…) [i] @c .m ?.m with {…} left, same level, lexical order
11 primary

The precedence ladder with a worked example expression
Figure — the ladder from loosest to tightest, and how one realistic expression parses along it.

Consequences worth spelling out:

fluxm = -close[1] * 2                 // (-(close[1])) * 2 — unary minus tighter than `*`, postfix tighter still
p = close > open                  // signal
q = volume > sma(volume, 20)      // signal
r = not p and q or p              // ((not p) and q) or p
s = m ?? 0.0                      // m ?? 0.0 — null-coalescing
t = close > open ? high : low     // (close > open) ? high : low
e = macd(close).hist[1]@"1d"    // (((macd(close)).hist)[1])@"1d" — postfix chain, lexical order
bad = a < b < c              // ✗ syntax error — comparisons do not chain (non-associative)

Outside the ladder. Three token families take no precedence level at all. The range .. is non-associative and appears only in its dedicated slots (fills, spans, input ranges, paren items, property values) — never inside the operator cascade, so bb.upper..bb.lower needs no parentheses. The clock suffix @ restricts its right operand to a clockOperand, so x@"1d" + 1 is (x@"1d") + 1 by construction. And : and , are pure separators.

Why stratification instead of precedence annotations. Each stratum refers only to the next tighter one — there is no cross-recursion anywhere in the expression grammar — so the LR automaton is the precedence table. Nothing needs to be declared, so nothing can be declared inconsistently; the non-associative comparison level is a production that does not repeat. The comparison ban on chaining exists because a < b < c has no boolean reading in a dimensional language (the first comparison yields a signal, which is not ordered against c — the grammar rejects the shape before the kind checker would).

Newlines. The statement separator TERM and its continuation rules interact with this ladder (an infix operator at a line start continues the previous line). The policy is lexical and specified in lexical structure.

#Disambiguation catalogue

Every place where two constructs could compete for the same token is closed by one of five devices: a tokenizer rule, the stratification, a guarding head keyword, distinct LR states, or one token of bounded lookahead. The design plan enumerates each potential conflict and its resolution; the grammar build re-proves the absence of conflicts mechanically on every change. The cases a reader actually meets:

The brace. All { readings are separated by where the brace is reached:

You see It is Decided by
{f: v, …} in expression position record literal lookahead after { IDENT is :
{x = 1; …; e} in expression position block expression lookahead after { IDENT is = / ; / }
{a, b} = e at statement level destructuring bind no : after { IDENT; statements never start with a brace-led expression
match e { … } match arms the match head owns this brace
e with { … } record update body reachable only after the with token
row { … }, panel(x) { … } view container brace after an IDENT/call head in statement or child position
plot … { … }, dot { … }, group { … } the one block (style/props/stmts) trailing block guarded by its statement head
variant T { … }, record T { … }, app N { … } declaration body keyword head

Two brace-led forms — and only two — can begin an expression (record literal, block expression), and one token after { IDENT separates them; a bare { a, b } in expression position is neither, and is rejected. Every other brace is reached after a guarding token, in an LR state where no expression can start — so the parser never forks on {.

The bracket. [ at the start of an expression opens a list literal; [ glued after a postfix is an index. The two sit in different automaton states (expecting-a-value versus holding-a-value), so no input reaches both. The single index form then carries two typing roles, chosen by the receiver's kind, not by grammar: on a scalar stream with a constant index it is the causal delay close[1]; on a vec it is element access slots[i] (runtime index, out-of-bounds yields na).

Items after an identifier. Inside a block, the token after a leading IDENT dispatches: : → property, = → binding, { → view container, ( → call (then a following { makes it a container; otherwise it stays a call — same Call tree either way, the container question is semantic), separator/} → bare flag or expression child.

when, twice. when c: child (a conditional view child, with :) versus dot { … } when c (a trailing guard on a primitive, without :) — the colon decides.

Contextual at. In assert cond "msg" at 500, at is a keyword only in that clause position (after the condition and optional message). It never collides with the property key at: of a canvas block, which is an item-level IDENT :.

The capability colon. chart:read is a single CAPREF token recognized only inside a capability list; the [ of capabilities: [ … ] is likewise reached only after capabilities :, never in expression position. No other colon in the language can occur in that state (details).

Contextual color. color is a keyword only as the head of the two-token sequence color bars; followed by anything else it is an ordinary identifier — which is what lets color name both a field and the color kind (record Stop { color: color }).

The ? family. The lexer's maximal munch orders ?? > ?. > ?; the productions then sit at three different strata (coalescing, postfix, ternary). The ternary's : is reachable only after ? expr, an LR state disjoint from every other colon.

Visibility prefixes. pub / private / package are modifiers exactly when followed by a declaration head or a binding; followed by =, :, . or ( they are plain identifiers. Since two adjacent identifiers are never a valid parse, pub def … commits deterministically.

#Formal properties

The grammar is frozen against six criteria, each with a machine verification — designing the bad states out, then checking by machine, rather than being careful:

Property Meaning Guaranteed by Verified by
Complete every intended program parses; every construct has a surface form the grammar is corpus-driven: each catalogued construct contributed a form the full example corpus parses to valid ASTs
Correct exactly the intended language; trees mirror structure (a-b-c = (a-b)-c) one normative grammar; total precedence & associativity conformance suite: positives with expected tree, negatives with expected diagnostic; round-trip parse → canonical format → re-parse yields the identical AST
Consistent no contradictory or dead rules a single grammar artifact; every non-terminal reachable the grammar generator's linter reports zero warnings
Unambiguous every valid input has exactly one tree an LR grammar class whose build fails on any conflict; each potential conflict resolved by a named device the build completes with zero unresolved conflicts; fuzzing finds no input with two trees
Decidable parsing the parser always terminates; linear and incremental LR(1) by stratification; no unbounded lookahead anywhere complexity profile; the incremental parser serves live preview within its frame budget
Semantically coherent every parsed program gets a defined meaning or a precise error — no gaps decidable analyses on the tree: kind inference on a finite-height lattice, clock-calculus causality, the plane firewall, totality by construction the typed corpus asserts expected kinds; negatives assert the exact diagnostic ([ErrDim], repaint attempts, …)

Three of these verifications deserve a sentence each:

Semantic coherence extends beyond parsing: for every kind, the admissibility of each operator, comparison, fill and plot is enumerated over the finite-by-family kind set, so no kind/construct pair is left without either a meaning or a named error. The verification harness is described in compiler and runtime and the guarantees themselves in guarantees.

#Additivity and versioning

The grammar evolves under a strict additivity policy:

Why this rule exists. A total, deterministic language is a promise about the future of a program, not just its present: a script that replays byte-identically today must still parse — and mean the same thing — under every later compiler. Strict additivity is the syntactic half of that promise; the pinned-routine and byte-identity invariants are the semantic half (compiler and runtime).

#See also

↑ contents

Kinds — the dimensional type system

Every value in Flux carries a kind: a statement of what the value means, not merely how it is stored. close is not "a float" — it is a price, a point on the price axis. close − open is not "another float" — it is a level, a displacement along that axis. rsi(close, 14) is a bounded oscillator, osc(0,100). Because meaning is tracked, the compiler rejects meaningless arithmetic at compile time, reconciles branches and co-plotted series soundly, and infers presentation — pane, scale, guides — from the kind alone. Kinds are the keystone of the language: causality, totality, presentation inference and the optimizer's guarantees all lean on them.

The mechanism is general. The same machinery kinds calendar arithmetic, exact decimal amounts, physical units (meas[u]), user-declared records and variants, and the APP plane's messages and views; market-data kinds are the flagship instantiation and serve as the running illustrations here. This chapter is the normative reference for the kind system itself — its two cooperating systems, its sorts and partial order, join and meet, its orthogonal tags, and named declarations. Per-operator rules live in Operators; the inference algorithm, the error policy in full, and the complete kind → presentation table live in Inference.

#Two systems, one error channel

"Join and meet of binary operations" is a category error, and the kind system is built on refusing it. price − price = level, yet price ⊔ price = price — subtraction and unification answer different questions. Flux therefore separates two systems cleanly:

Read the two rules above together and one thing follows that is easy to miss, and worth stating outright, because it is the difference between a type system that is strict and one that is merely loud: a mixture of dimensions never reaches . Add a price to an oscillator and the algebra has no rule, so you get and a hard [ErrDim]. But reconcile a price and an oscillator — take one branch of an if or the other — and the join does not fail. It erases the dimension and lands on quantity: a real kind, a number that remembers it is a number and has forgotten what of. That is a [WarnBranchDim], and the value flows.

The hard channel is for nonsense that is certain; the soft one for a mixture that is merely suspicious. What separates them is not consumption — it is where the ⊤ was born. still appears in a reconciliation, and is still hard when it does: an if whose branches cross sorts (a price and a colour) has no common kind at all, and no erasure can save it.

Two systems feeding one error channel
Figure — the coercion lattice (A) and the operator algebra (B) meet only in the single error channel ⊤; price − price → level flows through B, never through A.

Why this rule exists. If were modelled as a join, close − open would have kind price ⊔ price = price — a displacement mislabelled as a point, and every downstream rule (price + level → price, overlay placement, axis choice) would silently go wrong. If joins were modelled as algebra, if c then ema12 else ema26 would need an "operator rule" for a question that is really "do these two branches share a kind?". Keeping the two systems separate — and letting them fail into one shared — reconciles a total lattice (every pair of kinds has a join) with a system that still rejects nonsense (that join is exactly where no honest common kind exists).

fluxfast   = volume > sma(volume, 20)

spread = close - open              // price − price → level   (algebra, system B)
line   = if fast then ema(close, 12) else ema(close, 26)
                                   // price ⊔ price → price   (join, system A)
plot spread, line

// ✗ plot close + rsi(close, 14)   — [ErrDim]: point + dimensionless has no affine meaning

Three failures, and they are not the same failure:

fluxfast = volume > sma(volume, 20)

mix  = if fast then close else rsi(close, 14)   // ⚠ [WarnBranchDim] — no common dimension, so
plot mix                                        //   the join ERASES it: mix : quantity. It flows.

// ✗ plot close + rsi(close, 14)   — [ErrDim]: the ALGEBRA has no rule for point + dimensionless.
//                                   Hard, at the `+`, read or not: an operand is a demanding
//                                   position, and there is nothing suspicious about this — a
//                                   point plus a bare number is not a thing.
// ✗ if fast then close else up    — [ErrDim]: price ⊔ color = ⊤. Crossing SORTS is not a mixture
//                                   the lattice can erase — there is no common kind to land on.

[WarnTop] is the lint that sits on top of the first case: a binding whose dimension was erased and which nobody reads is almost always a mistake in the making, so leave mix unplotted and you get a second warning saying exactly that. Consume it and the [WarnTop] goes away — the [WarnBranchDim] stays, because the erasure is still there.

A also propagates as a kind poison without re-diagnosing: one root fault produces one message, and every node it contaminates stays silent (the anti-cascade rule; see Inference for the typable cone that keeps live preview working around it).

#The affine substrate

The asymmetry of the algebra is not a convention — it is geometry. The price axis is a one-dimensional affine space:

Affine geometry then hands the core rules over for free: point − point = vector (price − price → level), point ± vector = point (close + atr(14) → price), point ÷ point = scalar (price ÷ price → ratio), and "point + point" does not exist at all — which is exactly why close + rsi(close, 14) is refused rather than computed.

Above the affine pairs, a dimension is an element of an abelian group of integer exponents over the five generators {P, V, T, I, rad} — price, volume, time, ordinal bar index, angle. Multiplication adds exponent vectors, division subtracts them, so × and ÷ are total on dimensions (price × volume → pv is P·V; pv ÷ volume → price is P¹V¹−V¹). Only +, and the order comparisons demand equal dimension, because adding metres to kilograms — or points to displacements of a different axis — has no meaning.

fluxgap    = close - close[1]          // level      pt(P) − pt(P) = vec(P)
band   = close + 2 * atr(14)       // price      pt(P) + lit·vec(P) = pt(P)
rel    = close / open              // ratio      P⁰ — dimensionless, centered on 1
flow   = close * volume            // pv         P·V by the group law
speed  = change(close, 5) / (5 bars)  // slope    vec(P) ÷ vec(I) = P·I⁻¹ — price per bar

Three affine pairs live in the antichain, one per axis: price/level on P, time/ duration on T, and barindex/barspan on the ordinal axis I (rule A1 — the x axis is affine too, so barindex − barindex → barspan, and a regression slope is level ÷ barspan → slope = P·I⁻¹, price per bar, deliberately distinct from P·T⁻¹).

#The sorts

The lattice is stratified into sorts: fine structure inside each sort, and across sorts the join is and the meet is . The extrema are shared by all sorts: (any — the error channel, hard when demanded) and (never — the kind of na, which inhabits every kind: na : ∀κ.κ enters the lattice at and subsumes upward to whatever is expected).

#The scalar sort

Everything in the scalar sort sits below quantity, which sits below .

The dimensionless spinelit < {ratio, osc(lo,hi), signal, dir, depth} < num:

The dimensioned antichain — each element ≤ quantity, each ≥ lit, and all pairwise incomparable: price = pt(P) · level = vec(P) · volume = V (signed values allowed: obv) · pv = P·V (money-flow) · time = pt(T) · duration = vec(T) · barindex = pt(I) · barspan = vec(I) · slope = P·I⁻¹ · angle = rad — plus every composed dimension the group law can produce (, P²·V⁻¹, …), which are full plottable kinds presented in an auto pane labelled by their exponents, without warning (rule A3).

quantity — the erased dimension, least upper bound of the whole scalar sort. It is what a dimensionally incompatible join produces (see L2 below): still plottable, but suspect, and always flagged. quantity is reserved for genuinely erased or mixed dimensions; a precise composed dimension like never degrades to it.

#The categorical sorts — color, clock, string

Categorical kinds are flat (each is one element, joining only with itself) and live outside arithmetic; each is consumed by a dedicated eliminator rather than computed with:

#The structural sorts — vec and record

Structural kinds compose componentwise; mismatched shapes are nonsense and resolve to the extrema.

#The APP-plane sorts — variant and ui

The APP plane adds two sorts by the same construction, directly under , without reopening the sealed core lattice:

#No function sort

There is deliberately no arrow kind in the lattice. Lambdas are second-class: a (p⃗) -> body is admitted only where a higher-order kernel expects a function (window/fold/map/scan/loop, vec.where, sortBy, …) and is inlined at that site. A lambda can therefore never be a record field, a vec element, a variant payload or a let-bound value — which is what keeps every value representable as plain data, every buffer bounded, and compiled artifacts free of closures and function pointers. Recursion goes through def (whose call graph must be acyclic — [ErrTotalRec]) or through bounded scan/loop, never through a self-referencing lambda.

#The kind catalogue

One line per kind. The full presentation table (pane/overlay/scale/guides/css class per kind) is normative in Inference; the last column here is the one-line summary.

kind meaning dimension typical producers presents as
lit const-folded literal, dimension-polymorphic adopts context numeric literals, const params adopts its consumer
ratio dimensionless, centered 1, multiplicative P⁰ close/open, bbw, vortex pane around 1, guide 1
osc(lo,hi) bounded oscillator (interval family) dimensionless rsi, stochastic, mfi (0,100) · cmf (−1,1) pane, fixed [lo,hi], midline + refined guides
osc(-∞,∞) unbounded centered-0 oscillator (A4) dimensionless roc, cci, trix, coppock, fisher, kst pane, auto scale, guide 0
signal boolean event {0,1} dimensionless comparisons, cross_up, in_session marks / fills / bar color — never a line
dir direction {-1,0,+1} (A6) dimensionless superTrend(…).dir, SAR side bar coloring / marks — never a line
depth normalized z-intention (@z) dimensionless (analysis-produced; consumed by z) z axis in 3-D, flattened in 2-D
num dimensionless, role unknown (spine LUB) dimensionless kdj(…).j, calendar accessors pane fallback, auto scale
price point on the price axis pt(P) close, ema, vwap, bollinger fields overlay on the shared price axis
level price displacement vec(P) close−open, atr, stdev, macd fields pane centered on 0
volume base-asset count (signed allowed) V volume, obv, ad pane, signed auto scale, guide 0
pv money-flow P·V eldersForceIndex pane, auto, guide 0
time instant on the timeline pt(T) time x axis / annotation — never a series
duration exact elapsed time (machine) vec(T) time − time[n] x axis / annotation
barindex ordinal bar position pt(I) rollingExtremaIndex x axis / anchoring — never a series
barspan bar count vec(I) barssince pane counter [0,max]
slope price per bar P·I⁻¹ lrSlope (= level ÷ barspan) pane centered on 0
angle angle with unit @deg|@rad rad chopZone (angle@deg) style channel / pane [-π,π]
composed dims any other exponent vector (A3) ℤ-vector over {P,V,T,I,rad} price×price (P²), eom (P²·V⁻¹) auto pane labelled by exponents
quantity erased / mixed dimension (scalar LUB) erased lossy joins, non-normalized affine sums pane fallback — the erasure stays visible
color RGBA style value color constructors, gradients consumed by fill / bar color
clock resampling schedule tf, renko, pnf, range never plotted — eliminated by @
string bounded immutable UTF-8 text literals, interpolation, fmt.* consumed by labels/alerts — never a series
vec<κ>[n] fixed-capacity vector element κ window, list literals, vec.fill reduced / indexed, or a declared representation
record{…} named product per field bollinger, macd, adx, superTrend exploded per field
variant{…} tagged sum (APP plane) per payload Msg/Cmd/Sub, labelled inputs consumed by match — never plotted
ui view primitive (APP plane) button, panel, scene{…} consumed by the host renderer
any — the error channel failed joins, missing algebra rules not presentable — [ErrPlot]
never — the kind of na na not presentable

decimal(scale), period, the asset tag (B,Q[,@v]), meas[u] and metric[id] are orthogonal tags on these kinds, not kinds of their own — see Orthogonal tags below.

#The partial order

The kind lattice
Figure — the kind lattice: every sort under one ⊤ and over one ⊥; inside the scalar sort, the dimensionless spine rises to num while the dimensioned antichain rises only to quantity, and lit sits safely below every scalar.

The single most consequential decision in the order: dimensions form an antichain, and num is incomparable to every dimensionprice ⊀ num. Dimensioned kinds rise to quantity, never to num.

Why this rule exists. If price ≤ num held, then signal ⊔ price = num — a boolean event and a price would silently reconcile into an ordinary plottable number, and the error detection that motivates the whole system would evaporate at exactly the moment it matters (a co-plot or an if mixing incompatible things). By routing all dimensioned kinds to quantity instead, a mixture is never silent: in arithmetic it is refused ([ErrDim]), and in branch reconciliation it compiles to quantity and is flagged ([WarnBranchDim]) — visible, plottable, suspect.

Descending the order gains information, and presentation always reads the lowest kind known — which is why inference synthesizes the minimal (principal) kind and coerces only at consumption sites (see Inference).

#Two tiers of coercion edges

Every edge of the order is strictly rising, and each edge belongs to exactly one of two disjoint tiers:

Antisymmetry survives because both tiers rise strictly and never overlap.

#Literal polymorphism

lit is the ergonomic floor of the scalar sort. A const-folded literal coerces safely into any scalar, so the natural things are legal without ceremony:

fluxhot   = close > 30000              // price vs lit → the lit reads as price → signal
lift  = close + 10                 // lit adopts price → price
safe  = nz(change(close, 1), 0)    // level ⊔ lit = level
shift = rsi(close, 14) - 50        // osc(0,100) − lit → osc(-50,50): interval propagated

The compiler still lints impossible literals against claimed bounds (rsi(close,14) > 150 is [WarnLit]), because a lit adopting a kind does not suspend arithmetic sanity.

#Capacity, not length: vec<κ>[n]

The n in vec<κ>[n] is a const-folded capacity — an upper bound, not an exact count. A shorter vector inhabits a longer one through the ≤safe widening vec<κ>[k] ≤safe vec<κ>[N] (k ≤ N), its tail [k, N) reading na. Memory stays bounded by the declared cap, so totality holds; bounded iteration (map/fold) is na-aware on the capacity, so a widened tail contributes nothing. [ErrLen] fires only when two declared capacities are genuinely incompatible — otherwise the shorter operand widens to the longer. This is also why list literals of different lengths join cleanly: vec<κ>[k] ⊔ vec<κ>[m] = vec<κ>[max(k,m)].

fluxw    = window(close, 20)           // vec<price>[20] — a specific produced length
wide = nz(w, window(close, 50))   // vec<price>[50] — 20 widens into 50, tail na

Non-const or over-cap lengths are [ErrTotal] (n ≤ N_max = 10 000) — the price of "total, bounded memory" is that a capacity is always a compile-time fact.

#Join and meet

#The join catalogue

κ ⊔ κ = κ           ⊥ ⊔ κ = κ           ⊤ ⊔ κ = ⊤           lit ⊔ κ = κ
osc(L,H) ⊔ osc(L',H') = osc(min L L', max H H')          — the envelope
spine siblings → num          (osc ⊔ ratio = num · dir ⊔ signal = num)
D ⊔ D' (D ≠ D') = quantity    D ⊔ num = quantity    signal ⊔ price = quantity
vec<S>[k] ⊔ vec<T>[m] = vec<S ⊔ T>[max(k,m)]
record{f:A} ⊔ record{f:B} = record{f: A ⊔ B}             — differing field sets → ⊤
variant: same label set → per-payload join                — differing label sets → ⊤
across sorts → ⊤

Two placements deserve their reasons:

Joins land at exactly where genuine nonsense lives: across sorts (price ⊔ color, num ⊔ record{…}), on mismatched structural shapes (field or label sets that differ), and — the one enumerated in-sort exception — on equal dimension with differing representation tags ([ErrRepr], below). Mere dimension mixtures never reach ; they rise to quantity and stay visible.

fluxc = volume > sma(volume, 20)

a = if c then rsi(close, 14) else cmo(close, 14)
        // osc(0,100) ⊔ osc(-100,100) = osc(-100,100) — envelope
b = if c then close else rsi(close, 14)
        // price ⊔ osc = quantity — compiles, [WarnBranchDim], suggest two panes

#The meet catalogue and the lit correction

⊤ ⊓ κ = κ           ⊥ ⊓ κ = ⊥
quantity ⊓ D = D            num ⊓ ratio = ratio            — a constraint refines
osc ⊓ osc = interval intersection      (disjoint → lit, by L1)
incomparable scalars ⊓ = lit                                — correction L1
across sorts → ⊥            (lit does not cross sorts)
variant/record: same shape → per-component meet — differing shapes → ⊥

Correction L1 is load-bearing: the meet of two incomparable scalars — say price ⊓ volume, or dir ⊓ signal — is lit, not . lit ≤safe both operands, so lit is a common lower bound, and the greatest lower bound must sit at or above every common lower bound; forcing would contradict lit ≤ price ∧ lit ≤ volume ⇒ lit ≤ price ⊓ volume and break the absorption law (price ⊓ (price ⊔ level) = price ⊓ quantity = price holds precisely because meets refine rather than annihilate). Within the oscillator family the same correction resolves the empty intersection: two osc kinds with disjoint intervals are incomparable scalars, so their meet is lit — the empty interval is the bottom only of the interval sub-lattice viewed in isolation, never the global GLB.

#The osc interval lattice

osc(lo,hi) is a family ordered by interval inclusion: osc(L,H) ≤ osc(L',H') ⟺ [L,H] ⊆ [L',H']. Join is the envelope, meet is the intersection, and bounds may be infinite constants: osc(-∞,∞) (rule A4) is the kind of the entire percent-change family (roc, cci, trix, coppock, fisher, kst, chaikinVolatility, volumeOscillator) — dimensionless, centered on 0, additive, which distinguishes it cleanly from ratio (centered on 1, multiplicative) and from num (role unknown).

Bounds are presentation claims, not runtime invariants. The kind osc(0,100) asserts "this is presented on a fixed 0–100 scale with a midline"; only an explicit clamp makes a bound real at runtime, and arithmetic honestly propagates claimed intervals (rsi − 50 → osc(-50,50)). Rule A5 splits the catalogue accordingly: a bound genuinely enforced by the computation (rsi, stochastic, mfi, cmf) yields a fixed [lo,hi] scale; a merely conventional bound (adx, correl, balanceOfPower) is a claim — auto-scale plus indicative guides. A bound that failed to const-fold would fall back to num; no kernel in the catalogue produces one.

#Deep ⊤ propagation

vec<⊤>[n], record{f: ⊤, …} and variant{T: ⊤ | …} all reduce to . Without this, a buried mismatch — vec<price> ⊔ vec<color> producing vec<⊤> — would slip beneath every κ ≠ ⊤ guard and surface as a runtime mystery instead of a compile-time diagnosis. An error at any depth is an error of the whole value.

#Closure, verified by enumeration

With L1 and L2 in place, every pair of kinds has a unique LUB and a unique GLB. The kind set is finite by structural family: the dimensional core is finite, and every parameterized axis — osc bounds, vec capacity, decimal scale and precision, the representation tags, the asset-tag components and the fx pair annotation — is a const-folded tag whose keys are enumerable at the point of verification. The lattice laws (antisymmetry, absorption, uniqueness of LUB/GLB) are therefore machine-verified by exhaustive enumeration, family by family — not merely argued (Guarantees). Named record/variant declarations preserve this: their reference graph must be acyclic ([ErrTotalType]), so every named kind flattens to a finite-height structure.

#The operator algebra in brief

The full, normative per-operator tables are in Operators. The shape of system B, in one screen — a missing rule means , hence [ErrDim] when consumed:

fluxtrend = ema(close, 50)                    // price   ([CallPoly]: α = price)
hits  = count(close cross_up trend, 20)   // osc(0,20) — const-folded n
since = barssince(close cross_up trend)   // barspan — a bar count, not a num
bad   = close < rsi(close, 14)            // ✗ [ErrDim] — price ⊔ osc = quantity

#Orthogonal tags

Beyond its dimension, a kind can carry up to three orthogonal tags, plus one axis that lives only on ratio:

  1. a numeric representation tag — f64 (default) or decimal(scale) (rule A11);
  2. a time representation tag on the T dimension — machine (default; exact elapsed time — duration) or calendar (period, rule A10);
  3. an asset tag — a fixed-arity string-keyed n-uple (B, Q [, @v]) on price-dimension kinds (rule A9);

plus (4) the currency-pair annotation of fx — an axis carried only by ratio, whose top is the bare ratio itself. Since ratio never carries an asset tag, no kind ever carries four tags: a decimal price[BTC,USD] holds three (representation + asset + dimension), and that is the ceiling.

Orthogonal tag axes on a kind
Figure — the tag axes around one kind, and the two regimes: representation tags refuse to join ([ErrRepr]), asset components widen while ± demands identity ([ErrDim]).

The two axes families obey two different regimes — and the difference is the point:

For both regimes, identical tags fall back to the plain dimension rules with the tag preserved through ± × ÷: decimal price − decimal price = decimal level, period + period = period, price[BTC,USD] − price[BTC,USD] = level[BTC,USD].

#Numeric representation — f64 and decimal(scale)

decimal(scale) is exact fixed-point, orthogonal to dimension: a decimal always has a dimension, and the group law is unchanged (money ÷ qty → price with money ≡ pv, qty ≡ volume). The scale rides the kind and the system computes it: ± takes the max of the two scales, × sums them, and ÷ is the one non-closed operation — it requires an explicit target scale and rounding mode. Declared precision picks the narrowest backing (i64/i128/i256); exceeding the declared precision yields a deterministic na plus a diagnostic, never a silent wraparound. Literals are suffixed: 1.50d is decimal(2), 1.5 is f64. Decimal's domain is settled money — order amounts, fills, balances; the analysis kernels remain f64.

fluxexact = toDecimal(close, 2)        // decimal(2) price — explicit entry, rounds
bad   = close + exact              // ✗ [ErrRepr] — f64 price + decimal price
worse = if c then close else exact // ✗ [ErrRepr] — the join refuses the same mixture
fine  = toDecimal(close * volume, 2) + toDecimal(close * volume, 2)  // decimal(2) pv — a money amount adds; same representation, scales combine

#Time representation — machine and calendar

time = pt(T) is an instant (an int64 epoch under the hood); duration = vec(T) is exact elapsed machine time. period is the same vec(T) tagged calendar — a months-and-days quantity that is aware of zones and daylight-saving transitions. Both t + duration and t + period are dimensionally pt(T) + vec(T) → pt(T); the tag is what separates "exactly 24 hours later" from "the same wall-clock time tomorrow", and the two never mix silently:

fluxage    = time - time[20]                 // duration — machine-exact
renew  = time + time.months(1)           // time — calendar arithmetic, DST-aware

// ✗ (time - time[20]) + time.months(1)  — [ErrRepr]: a duration and a period do not add

period values are produced only by the constructors time.years/months/weeks/days(n) (composable: time.months(1) + time.days(10)); calendar accessors (year, dayOfWeek, …) project a time into a declared zone as num, and out-of-range calendar arithmetic yields a deterministic na, never a wraparound. The pinned time-zone database and the shared conversion routine that make this reproducible are specified with the compute pillar (Compute).

#The asset tag — (B, Q [, @v])

A price is never a bare number: it is a rate, quote-per-base. The asset tag makes that identity dimensional, per kind:

kind tag reading
price[B,Q], level[B,Q] base + quote a rate and its displacement — both components ride
volume[B] base only a count of the base asset — no currency
pv[Q] quote only a money amount — the base cancels in price × volume and is deliberately dropped

The quote rides every dimension containing the P factor (including composed , slope); dimensionless kinds carry no asset tag at all. The default is mono-asset price[primary, baseccy], byte-identical to a single-series script that never mentions the axis; tags become concrete only where series keys are static literals, and widen to component tops otherwise. The venue component @v is opt-in (default off; absence is the concrete composite level, not ⊤venue).

fluxc      = volume > sma(volume, 20)
btcUsd = series("BTC-USD").close        // price[BTC,USD]
btcEur = series("BTC-EUR").close        // price[BTC,EUR]
ethUsd = series("ETH-USD").close        // price[ETH,USD]

diff   = btcUsd - btcUsd[1]             // level[BTC,USD]
mixA   = btcUsd + ethUsd                // ✗ [ErrDim] — mixing assets
mixQ   = btcUsd + btcEur                // ✗ [ErrDim] — mixing currencies
cmpQ   = btcUsd < btcEur                // ✗ [ErrDim] — comparisons gate on identity too
rel    = btcUsd / ethUsd                // ratio — cross-base relative strength, tag dropped
either = if c then btcUsd else btcEur   // price[BTC,⊤quote] — the join widens, never errors

#fx and money — existing kinds, tagged

Currency conversion needs no new sort. fx[Q1/Q2] is the kind ratio carrying an optional, ordered currency-pair annotation — the fourth tag axis, living only on ratio, top = the bare ratio. money[Q] is an alias for decimal pv[Q]. Zero new sorts, zero new lattice height. An fx arises from a division of same-base, different-quote prices, or from a feed declared as an fx series; conversion is a type-checked unit cancellation:

fluxc         = volume > sma(volume, 20)
btcUsd    = series("BTC-USD").close
btcEur    = series("BTC-EUR").close
eurGbp    = series("EUR-GBP").close      // an fx series, declared as one

usdPerEur = btcUsd / btcEur             // fx[USD/EUR] — same base, quotes differ
inUsd     = btcEur * usdPerEur          // price[BTC,USD] — the shared quote cancels
flipped   = 1 / usdPerEur               // fx[EUR/USD] — the reciprocal edge
avg       = if c then usdPerEur else eurGbp   // ratio — differing pairs join to bare ratio

A pair mismatch in ×/÷ widens to ⊤quote rather than erroring (the × regime never hard-fails); the pair annotation is excluded from the comparison identity gate, so two fx rates always compare as the ratios they are. The complete edge catalogue — derivation, reciprocal, triangular chaining, conversion in both operand orders — is in Asset & currency.

#Unit and metric annotations

Two further tag axes extend the same construction beyond finance, both carried by num alone, both with the bare num as top:

fluxwarm = unit.tempC(20) + 5                // a point ± lit → point — the lit reads as a delta
span = unit.tempC(25) - unit.tempC(20)   // point − point → a 5-degree delta

#Named records and variants

Structural kinds can be declared and named at program level:

fluxrecord Band { upper: price, middle: price, lower: price }

variant Tool  { Select | Draw | Erase }
variant Save  { Saved | Failed(code: num) }

t = Tool.Select                     // qualified constructor — resolves homonyms
s = Save.Saved                      //   across variants that share a label

A nullary constructor is read as a value of its variant; a payload-carrying constructor is applied like a function, its payload checked against the declared field kinds. The qualified form T.C selects the constructor of the named variant T — resolved at name resolution, before field projection ever applies, so label collisions across variants are a non-problem.

Two disciplines keep named declarations inside the sealed lattice guarantees:

fluxrecord Node { next: Node }          // ✗ [ErrTotalType] — cyclic reference, unbounded height

#A worked example: the lattice refuses wrong physics

Point & Figure charting is a stress test the kind system passes without a single new kind — and it shows the dimensional lens doing real work. Every piece kinds naturally: pnf(box, rev) is a clock; the column state is a scan over a plain record; and the box size must be a level, because the geometry says so:

fluxbox    = atr(14)                    // level — a displacement, by construction
anchor = close                      // price — a point
edge   = anchor + 3 * box           // price ✓ — pt(P) + lit·vec(P) = pt(P)

// the column state the frontier belongs to — a plain bounded scan over a record
state  = scan({ dir: 1, extreme: close, count: 0 },
               (p) -> if close > p.extreme + box
                        then { dir: 1, extreme: close, count: p.count + 1 }
                        else p)

Suppose you had declared the box a price. Then anchor + box would be price + price — point + point, no affine meaning — and the compiler answers [ErrDim] on the spot. The lattice does not merely permit the correct model; it refuses the incorrect one. The same lens types the rest of the state: count is a dimensionless box count (num), so count * box is num · vec(P) → level and extreme + count * box lands back on price; and count is deliberately not a barspan — boxes are not bars, and the kinds keep the two countings from ever being confused.

#Errors and na at the kind level

The error policy in full — including causality, totality and firewall diagnostics — is specified in Inference; the kind-level skeleton is:

na deserves its kind-level statement: na synthesizes and subsumes into every expected kind, so absence is a value of every kind, never a separate shape. Runtime na propagates through arithmetic with the kind preserved, and every comparison touching na is itself na — never true or false. You test absence explicitly:

fluxm   = sma(close, 200)               // price — na during warm-up
odd = m == na                       // legal, but always na — never true
hit = is_na(m)                      // signal — the way to ask
has = is_some(m)                    // signal — its dual

A match on a possibly-na scrutinee must cover it (an na or _ arm), and destructuring an na record yields na in every field — absence composes structurally, with no special cases to memorize.

#See also

↑ contents

Operators and expression semantics

This page is the reference for the operator layer of Flux: what each operator means, which kinds it accepts, what kind it produces, and why each rule is the way it is. It covers the dimensional algebra of + - * /, the two comparison families, logic on signal, the delay x[n], the resample e @ clock, ranges, indexing and projection, UFCS method syntax, record update with with, destructuring, the ? family of sugar, the _ placeholder, and the full precedence table. How kinds are assigned to whole programs — synthesis, checking, principality — is the subject of Inference; the lattice the operators compute over is defined in Kinds.

A note on the samples. Flux has no expression-statements, so a bare expression is not a program. The lines marked on this page are therefore expression fragments: they exist to show what the kind rules refuse, not what the parser accepts. Every unmarked line is a legal statement.

#One algebra, two systems

Two distinct systems cooperate under every expression, and keeping them apart is what makes the rules below predictable:

Both systems report failure through a single error channel: any undefined rule of (B) and any incompatibility of (A) produce . A is a hard error only in a demanding position (an operand, a plot target, an argument); a bound to an intermediate name that nothing consumes is a warning ([WarnTop]). The policy is detailed in Inference.

The affine substrate. The price axis is a one-dimensional affine space: price is a point on it, level is a vector (a displacement), ratio is a dimensionless scalar centred on 1. Dimension itself is an abelian group of exponents over the generators {P price, V volume, T time, I bar-index, rad angle}: * adds exponents, / subtracts them. Two consequences fall out for free:

Why this rule exists. The affine reading is not decoration; it is what lets the checker say no to close + rsi(close, 14) while accepting close + atr(14) — both are "price plus a number" to an untyped eye, but only one of them names a displacement on the price axis. Every rule below is an instance of this substrate, not a special case.

#Addition and subtraction: + and -

+ and - require operands of the same dimension and then follow the affine rules:

rule reading example result
pt(d) - pt(d) → vec(d) point − point = vector close - open level
pt(d) ± vec(d) → pt(d) point ± vector = point close + atr(14) price
vec ± vec → vec vectors add atr(14) - atr(28) level
ratio ± ratio → ratio scalars add vortex(14).plus - vortex(14).minus ratio
osc ± osc → propagated interval interval arithmetic on the claim rsi(close,14) - 50 osc(-50,50)
lit ± x → x a literal adopts the dimension close + 10 price
dims ≠ → no affine meaning close + rsi(close,14) [ErrDim]
fluxspread   = close - open                  // price − price : level
band     = close + 2 * atr(14)           // price + level : price
centered = rsi(close, 14) - 50           // osc(0,100) − lit : osc(−50,50)
close + rsi(close, 14)                   // ✗ [ErrDim] — point + dimensionless: no affine meaning
obv() + close                            // ✗ [ErrDim] — volume and price do not add

The osc line deserves a note: the bounds of an osc(lo,hi) are a presentation claim, not a runtime invariant (only clamp makes a bound real), and ± propagates the claim by interval arithmetic. Subtracting the midline from an oscillator therefore re-centres its pane: rsi - 50 is drawn in a fixed [-50,50] pane with a midline at 0.

There is no arithmetic on signal — combine signals with and/or/not (see Logic), count them with count(sig, n).

#The one categorical overload: string concatenation

The only operator line outside the dimensional table is concatenation: string + string → string. - * / and every mixed pair involving string remain outside arithmetic and produce [ErrDim].

fluxlabel = "px " + fmt.price(close) + " @ " + fmt.time(time)   // string
"holdings: " + volume                                       // ✗ [ErrDim] — format it: fmt.num(volume)

Interpolation ("px {fmt.price(close)}") desugars to the same concatenation pipeline (fmt.cat), so the two spellings are equivalent; chains of + fuse into a single bounded write at compile time. See text for the string kind's guarantees.

#Affine combinations: the Σλ rule

A weighted sum of points is meaningful exactly when its coefficients say so. On an expression normalised to Σ λᵢ · ptᵢ (coefficients are constant-folded literals, so x * 0.5 and x / 2 are the same λ, and (2*high + low + close) / 4 normalises fine), the checker applies [Affine]:

Two refinements make this rule cover real indicator algebra:

The common OHLC barycentres are pre-typed sources (hl2, hlc3, ohlc4 : price), so the 90 % case never even exercises the rule.

Asset tags gate the combination. All the points in one affine combination must carry the same asset tag, component by component, exactly as ± demands (see below):

fluxbtcUsd = series("BTC-USD").close         // price[BTC,USD]
btcEur = series("BTC-EUR").close         // price[BTC,EUR]
mid    = (btcUsd + btcEur) / 2           // ✗ [ErrDim] — quotes differ; convert one leg first

Why this rule exists. Without the tag clause, (btcUsd + btcEur) / 2 would normalise to Σλ = 1 and type-check as a valid price, silently averaging two currencies. The affine rule must not be a back door around the mixing ban.

#Multiplication and division: * and /

* and / are total on dimensions: the result's exponent vector is the sum (respectively difference) of the operands'. The named kinds are the exponent vectors you meet most often; any other combination is still a valid, plottable composed dimension labelled by its exponents (eom : P²·V⁻¹ gets an auto pane — no warning, because a precise composed dimension is not an erased one).

rule example result
D × ratio → D close * (volume / sma(volume, 20)) price
lit × κ → κ 2 * stdev(close, 20) level
D × osc(0,1) → D close * bbPctB(close, 20) price
price × volume → pv close * volume pv (money flow)
price × price → P² close * close , labelled pane
price ÷ price → ratio close / close[1] ratio
level ÷ level → ratio atr(14) / atr(28) ratio
level ÷ price → ratio atr(14) / close ratio
D ÷ ratio → D close / historicalVolatility(close, 20) price
pv ÷ volume → price cum(close * volume) / cum(volume) price (vwap by hand)
pv ÷ price → volume group law volume
level ÷ barspan → slope change(close, 20) / barssince(sig) slope = P·I⁻¹
fluxrel   = close / close[1]                     // ratio — centred on 1, own pane
flow  = close * volume                       // pv — money flow, own pane
vwap0 = cum(close * volume) / cum(volume)    // price — back on the chart

Notes on the corners:

#Asset and currency tags under the operators

Kinds of price dimension carry an asset tag (B, Q[, @v]) — base, quote, optional venue; volume carries the base alone, pv the quote alone. The operators treat the tag with two different regimes, and the split is deliberate:

fluxbtcUsd = series("BTC-USD").close      // price[BTC,USD] — a quote-tagged close
d = btcUsd - btcUsd[1]     // level[BTC,USD] — tag preserved through ±
btcUsd + ethUsd            // ✗ [ErrDim] — bases differ: adding two assets
btcUsd + btcEur            // ✗ [ErrDim] — quotes differ: adding two currencies
pnlUsd + pnlEur            // ✗ [ErrDim] — pv[USD] + pv[EUR]: convert first

Division derives exchange rates, and multiplication consumes them — the fx role rule:

fluxbtcUsd = series("BTC-USD").close      // price[BTC,USD]
btcEur = series("BTC-EUR").close      // price[BTC,EUR]
ethUsd = series("ETH-USD").close      // price[ETH,USD]
fxUE  = btcUsd / btcEur    // fx[USD/EUR] — same base, quotes differ, concrete keys
cross = btcUsd / ethUsd    // ratio       — cross-base: relative strength, tag dropped
usd   = btcEur * fxUE      // price[BTC,USD] — shared quote cancels: (EUR/BTC)·(USD/EUR)
usd2  = btcEur / (1 / fxUE)          // ÷ fx[EUR/USD] converts the same way

// triangular chaining — both operands are DERIVED; no primitive conjures a rate
btcGbp = series("BTC-GBP").close      // price[BTC,GBP]
gbpEur = btcGbp / btcEur              // fx[GBP/EUR]
usdGbp = fxUE / gbpEur                // fx[USD/EUR] ÷ fx[GBP/EUR] → fx[USD/GBP]

The complete edge list, in one place — the price ÷ price dispatch is a 2×2 on (base equal?, quote equal?):

There is no fxRate(a, b) primitive: an fx value is derived by ÷ or arrives as a series whose producer declares itself an fx feed. The full model — venue metadata, toSource, mixed-currency series — is specified in asset-currency.

#Comparisons: two families, two scopes

A single "the join is defined" test would be the wrong gate: κ ⊔ κ = κ makes every self-join admissible, including kinds with no order and no useful equality. Flux instead scopes each comparison family to the sorts where its meaning is real. Both families return signal.

#Order: < > <= >= cross_up cross_down

Order comparisons are restricted to the scalar sort with a real total order, and the two sides must be dimensionally compatible (κₐ ⊔ κᵦ ∉ {quantity, ⊤}):

fluxbreakout = close > 30000                       // price ⊔ lit = price → signal
overbought = rsi(close, 14) > 70               // osc vs lit → signal
golden = ema(close, 50) cross_up ema(close, 200)  // signal — true on the crossing bar
close > rsi(close, 14)      // ✗ [ErrDim] — price ⊔ osc = quantity: incomparable magnitudes
tf("1d") < tf("4h")         // ✗ [ErrDim] — a clock has no order to compare on
superTrend(10, 3).dir > 0   // ✗ [ErrDim] — dir is categorical: write dir == 1

cross_up / cross_down are infix comparison operators (never prefix calls): a cross_up b is true exactly on the bar where a moves from below-or-equal to above b. They sit at the comparison tier and are non-associative like the rest of it.

Asset tags gate order too: operands must carry identical tags component-by-component — price[BTC,USD] < price[BTC,EUR] is [ErrDim] even though the join of the two sides would widen to a valid price. The fx pair annotation is exempt from the gate (it lives on ratio, outside the asset axis): fxUE < fxGJ → signal compares two rates as plain ratios.

#Equality: == and !=

Equality reaches further than order — onto every sort whose equality is decidable and terminating:

fluxst = superTrend(10, 3)
mark st.dir == 1                        // dir compares by equality → signal
bb   = bollinger(close, 20)
same = window(close, 4) == window(close, 4)   // deep, na-aware equality on a vec → signal
tf("1d") == tf("1d")                    // ✗ [ErrArg] — clock is consumed, not compared

#Comparisons and na

Any comparison touching na yields na — never true, never false: na == x, na < x, even na == na are all na. Absence is tested explicitly:

fluxhave = is_some(rsi(close, 14))     // signal — presence (not is_na(x))
gap  = is_na(close[1])             // signal — absence (first bar)

Why this rule exists. During warm-up an indicator is na for its first bars. If na > 70 silently evaluated to false, every threshold rule would fire — or refuse to fire — on phantom data, and the bug would be invisible by construction. Forcing na through comparisons makes absence propagate to the signal, where is_na / is_some handle it deliberately. The same discipline runs through the whole language: match must cover na, and window reducers propagate it.

#Logic: and, or, not

The logical operators are defined on signal and only on signal (signal × signal → signal); not is a prefix operator one tier tighter than and.

fluxsetup = close > ema(close, 50) and rsi(close, 14) < 30
flat  = not in_session("09:30-16:00 America/New_York")
close and volume        // ✗ [ErrArg] — `and` demands signals; neither operand is one

There is no short-circuit effect to speak of — expressions are pure — so and/or are plain boolean algebra on the {0,1} carrier, na-aware like everything else.

#Delay: x[n]

x[n] reads the value of the stream x from n bars ago. The index must be a constant-folded natural literal:

fluxmom   = close - close[10]          // level — momentum as a displacement
prevH = macd(close).hist[1]        // projection, then delay — postfix chain
close[-1]                          // ✗ [ErrCausal] — the future is not addressable
close[input(5)]                    // ✗ [ErrTotal] — delay must be a compile-time constant

The same bracket syntax on a vec receiver is not a delay but an element read ([Index]): slots[h.slot] takes a runtime ordinal index, and an out-of-bounds read yields na rather than an error — the declared capacity already bounds the cost. The two roles are distinguished by the receiver's kind, never by guesswork; see Indexing and projection.

#Resample: e @ clock

@ is the eliminator of the clock kind: e @ c re-times the stream e onto the clock c and preserves e's kind ([At]). Clocks are first-class values — composable, assignable, acceptable as input — and @ is how they are consumed:

fluxcalm   = atr(14) < atr(50)                 // signal — a quiet-volatility regime
daily  = ema(close @ "1d", 50)             // MTF: a daily EMA under any chart timeframe
bricks = close @ renko(atrBox(14))         // representation change = clock change
c      = if calm then tf("1d") else tf("4h")
trend  = ema(close @ c, 20)                // the clock itself was computed

Clocks generalise "timeframe" — the full treatment (warm-up, alignment, live()) is in time-and-state.

#Ranges: a..b

.. builds a range and exists only in range positions — it is not an expression operator, cannot be chained (non-associative), and never collides with . projection or number literals (2..200 lexes as 2 .. 200).

fluxbb = bollinger(close, 20)
fill bb.upper..bb.lower            // band between two price streams
len = input(14, 2..200)            // bounded parameter range

The two ends of a fill must live on the same ordered scalar axis — fill close..rsi(close, 14) is [ErrDim] (see Inference for the admissibility rules).

#Indexing and projection

Projection e.f reads a field of a record ([Proj]). A missing field or a non-record receiver is [ErrField] — with a nearest-name quick-fix:

fluxbb = bollinger(close, 20)          // record{upper, middle, lower : price}
plot bb.upper                      // price
bb.uper                            // ✗ [ErrField] — no such field; did you mean upper?

Indexing v[i] on a vec<κ>[n] receiver reads an element ([Index]). The index may be a runtime ordinal; the declared capacity n keeps the operation total, and out-of-bounds reads yield na — never [ErrTotal], never a trap. This is the substrate of the slotmap idiom (bounded collections with stable handles and na tombstones).

A dotted name can also be a qualified name rather than a projection — T.Ctor selects a constructor of the declared variant T, and mod.f names an entry of an imported module. Both resolve during name resolution, before [Proj] is ever considered. Which brings us to the dot's third meaning:

#UFCS: close.ema(20).rsi(14)

Uniform Function Call Syntax: recv.f(args) is exactly f(recv, args) — the receiver becomes the first argument. It is a semantic desugaring on the already-parsed tree, not a grammar form, and it is what makes left-to-right pipelines read the way the data flows:

fluxsmooth = close.ema(20).rsi(14)                      // ≡ rsi(ema(close, 20), 14)
top5   = vec.topK(window(close, 100), (x) -> x, 5)  // a namespace call — the dot means something else here

The dot has three meanings, decided at compilation with one token of lookahead:

  1. recv.f without ( — a record field (bb.upper), rule [Proj];
  2. recv.f( where f resolves to a function — a UFCS call, desugared to f(recv, …);
  3. mod.f / T.Ctor where the left side names a module or a declared variant — a qualified name, resolved before either of the above.

Tie-break: if recv has a field f and f names a function, recv.f(…) is the UFCS call — a field is never callable, so the other reading could only be an error.

The trap is reading (2) where only (3) applies. UFCS reaches the functions in scope — the prelude — and topK is not one of them: it lives in vec.*. So w.topK(…) is not a UFCS call but a field projection on a vector, which is [ErrField]. Write vec.topK(w, …). The rule of thumb is the one the completion menu already enforces: if the editor does not offer it after the dot, it is not there.

Why this rule exists. UFCS adds zero grammar and zero semantics — the checker verifies recv against the function's first parameter exactly as if you had written the direct call — but it buys discoverability: after close., the editor can propose precisely the functions whose first parameter accepts a price, filtered by kind. One mechanism, readable pipelines, kind-aware completion.

Named arguments compose with it: recv.f(x, mode: fast) binds mode by parameter name and checks it like a positional (see Inference).

#Record update: e with { … }

with produces a record identical to e except for the listed fields. It is shape-preserving ([With]):

fluxvariant Phase { ask | suspense | revealed }
m  = { score: 0, phase: ask }
m2 = m with { score: m.score + 1, phase: revealed }
m with { scrore: 0 }        // ✗ [ErrField] — typo caught, nothing silently added

with is a postfix suffix (same tier as call and projection), so it chains: m with { a: 1 } with { b: 2 }.

Why this rule exists. Functional update without with means rebuilding the whole record by hand — and a forgotten field is silent state loss. Shape preservation turns that class of bug into [ErrField] at compile time.

#Destructuring: let {a, b} = e

A record pattern on the left of a binding extracts fields by name. It is irrefutable — it can never fail at runtime, so it is allowed exactly where no branching is possible:

fluxw = let { upper, lower } = bollinger(close, 20) in upper - lower   // level — `let … in` is an EXPRESSION
{ macd, hist } = macd(close)                                       // a program-level bind destructures too

#The ? family: ??, ?., ternary

Three pieces of pure sugar, all desugared to sealed constructs — no new kinds, no new semantics:

sugar desugars to notes
x ?? d nz(x, d) kind = x ⊔ d; fills na with a default
e?.f if is_na(e) then na else e.f safe navigation; chainable, short-circuits at the first na
c ? a : b if c then a else b same tree; c must be a signal
fluxm     = macd(close)                       // record{macd, signal, hist}
len   = close[1] ?? close                 // price — first bar handled
hist  = m?.hist ?? 0                      // safe-nav then default
side  = close > open ? 1 : -1             // ternary, right-associative

Because ?? is nz, it follows the join rules: branches of different dimensions widen toward quantity (with a warning), and mixing representation tags (f64 vs decimal) is [ErrRepr] — the sugar cannot do what the desugared form would not.

#The _ placeholder

At the argument site of a higher-order kernel, a single free _ denotes the one parameter of an implicit lambda:

fluxscaled = window(close, 20).map(_ * 1.1)      // ≡ .map((x) -> x * 1.1)
present = vec.where(window(close, 20), (x) -> is_some(x))  // a namespaced HOF — write the lambda
window(close, 20).fold(0, _ + _)   // ✗ — two-parameter position: write (acc, x) -> acc + x

The placeholder is confined to a prelude kernel reached by UFCS, where the receiver fixes the one parameter; a namespaced higher-order call such as vec.where(…) takes an explicit lambda, because _ has no binder to attach to there.

The rule is strictly mono-argument: exactly one _, in a position expecting a one-parameter function. Two or more _, or a two-parameter site like fold, are rejected with a request for an explicit lambda — the sugar never guesses which _ is which. The pattern _ of match arms is a different, position-distinct symbol; the two never collide.

#Precedence and associativity

From loosest to tightest. The normative stratified grammar — including why this table needs no precedence annotations at all — is in grammar.md.

tier operators associativity notes
1 -> right one arrow token; its role (lambda, tween pair, on, match arm, for) is decided by its head position — see grammar.md
2 c ? a : b · if…then…else · let…in right else is mandatory — no dangling-if
3 ?? right null-coalescing
4 or left
5 and left
6 not prefix
7 < > <= >= == != cross_up cross_down non-associative a < b < c is a parse error — write a < b and b < c
8 + - left
9 * / % left
10 unary - prefix tighter than *: -a * b = (-a) * b
11 postfix: f(…) · [n] · @c · .m · ?.m · with {…} left one tier, applied in lexical order: f(x)[1]@"1d".m = (((f(x))[1]) @ "1d").m
12 primary literals, names, (…), […] list, {…} record/block, match, scene

Outside the cascade:

Each tier refers only to the next tighter one — no cross-recursion — which is what makes the whole cascade unambiguous by construction rather than by side condition.

#See also

↑ contents

Inference — kinds, presentation, and the error policy

Kinds gives the relation: which kinds exist and which judgments hold. This page gives the algorithm: how a kind is actually assigned to every node of a program, how the presentation of a chart — pane, scale, guides, colour, parameter UI — is derived from those kinds rather than configured, and what happens when something does not type.

Three properties make the algorithm worth specifying precisely, rather than leaving it as an implementation detail. It is principal (the kind it synthesizes is the least one the program admits, so there is never a choice to make), it is deterministic (the same source always yields the same kinds, which is what lets two engines emit byte-identical code), and it is total (every editing state, including a half-typed line, gets a kind or a precise error — never a silent failure).

#Two modes

Inference is bidirectional: it reads the same typing rules in two directions.

SynthesisΓ ⊢ e ⇒ κ — is the default, bottom-up mode. It produces the smallest kind the expression admits. Leaves synthesize their exact kind (close ⇒ price, 14 ⇒ lit, "hi" ⇒ string); introductions synthesize their structure (a record literal, a scene, a constructor); eliminations synthesize by computing — a call, a projection, a match, a delay, a resample, and every arithmetic node, which asks the dimensional algebra for its result kind.

CheckingΓ ⊢ e ⇐ κ — is the top-down mode, and it runs only at sites of consumption, where an expected kind already exists:

Consumption site What is checked
a call argument eᵢ ⇐ πᵢ — the parameter's declared kind
a record field, a with update vⱼ ⇐ κ_field
an output statement plot e ⇐ presentable · mark s ⇐ signal|dir · fill a..b ⇐ ordered-scalar · color bars: ⇐ signal|dir|color
an APP-plane init value or update arm field the Model's kind
a lambda (p⃗) -> body ⇐ (π⃗)→ρ — a lambda never synthesizes; it is checked against the function kind the higher-order kernel demands

That last row is also what disambiguates the arrow: (x) -> x * 1.1 is a lambda exactly when its position expects a function, and a tween pair otherwise. Disambiguation is a consequence of the mode, not a separate rule (see Grammar).

#Subsumption is confined

The coercion rule — "an a may be used where a b is expected if a ≤ b" — fires only at the boundary between the two modes. To check e ⇐ κ: synthesize e ⇒ κ', then demand κ' ≤ κ. Silent if the edge is ≤safe; a warning with a quick-fix if it is ≤lossy; [ErrDim], [ErrArg] or [ErrPlot] — according to the position — if κ' ⊀ κ.

Coercion never fires during synthesis. A node is never spontaneously widened; its synthesized kind stays the lowest one known.

Why confinement is load-bearing. Suppose subsumption were allowed in synthesis. Then x = close - close could synthesize level or, by the lossy erasure edge, quantity. Both are derivable. But plot x reads the presentation registry at the kind: level gives a pane centred on zero, quantity gives a fallback auto pane. One program, two valid outputs, two different compiled artifacts — and the guarantee that the editor's preview matches the shipped module (I7) would be gone. Confinement is what makes "the kind of an expression" a function rather than a choice.

#Principality

With synthesis defined as above, every expression has a principal kind: the unique smallest kind it admits. The proof is a one-line induction — each leaf synthesizes exactly, each node combines its children's minimal kinds with either or the dimensional algebra (both of which return a unique result, verified by enumeration), and the only widening rule is excluded from the mode. So the synthesized kind is the least one, and checking at each consumer is then complete.

The multi-site case. A field initialized to na synthesizes at that site — but its kind is not stuck there: the principal kind of a record field is the join over all its construction and assignment sites. In the APP plane, a Model field written picked: na in init and picked: key (a string) in one arm of update has kind string, with na remaining a legal runtime value of that field (⊥ ≤ string). The fixpoint converges in one pass because the kinds being assigned never depend on the record itself. Declaring record Model { … } up front is the same thing written explicitly.

#Termination and determinism

The algorithm is a single bottom-up pass over the topologically sorted graph. It terminates because the lattice is finite by family and of finite height, the graph is acyclic (causality guarantees that), and , and the algebra are all O(1) — there is no fixpoint iteration and no store of unification variables anywhere.

It is also confluent: a node's synthesized kind depends only on the kinds of its inputs, and is commutative and associative, so the result is independent of which topological order was chosen. Inference is therefore a deterministic function of the graph — the same program always yields the same kinds, hence the same emitted module. This is one of the foundations of the interpreter ≡ WASM byte-equality (see Compiler and runtime).

#Bounded polymorphism, resolved then forgotten

The catalogue is full of kind-preserving families: ema, sma, sum, highest, change, stat.stdev — each written (src: α ≤ quantity, len: lit) → ρ(α). This is the only polymorphism in v1, and it is deliberately shallow.

At each call site, α is resolved to a closed, monomorphic kind: α := the join of the kinds synthesized for the arguments in α positions; then α ≤ quantity is checked ([ErrArg] otherwise); then the return kind is the family's shape instantiated at that α. For difference families, the shape is the δ derived from the frozen ± algebra: δ(price) = level.

fluxa = ema(close, 20)              // α := price      ⇒ price
b = ema(rsi(close, 14), 9)      // α := osc(0,100) ⇒ osc(0,100)   — smoothing preserves the kind
c = change(close, 5)            // δ(price)        ⇒ level
d = change(rsi(close, 14), 5)   // δ(osc)          ⇒ osc, centred on 0

α is resolved and then forgotten. It is not a unification variable that persists across the program, so two sites can never contradict one another, and the "no general unification" property that keeps inference a single pass survives intact.

#Typing an unfinished program

An editor types a program that is being written, not one that is finished — so the algorithm assigns a kind to every editing state.

An unbound name (you are halfway through typing it) and a syntax hole (a parse error node) both synthesize a kind hole: a contained that emits exactly one diagnostic ([ErrUnbound]) and does not poison its siblings.

From that follows the typable cone: the largest sub-graph in which every node, and every transitive input of every node, is free of and free of holes. Live preview evaluates exactly that cone and renders the rest as --. A local mistake therefore never blanks the whole preview — a correct program has a cone equal to the whole graph, and a program with one unbound name still previews everything that does not depend on it.

The cone is a strictly editing-time artifact: a in a consumed position is still a hard failure to emit code. The byte-identity guarantee is untouched.

#Incremental re-typing

On an edit, only the changed node and its downstream cone of kind consumers are re-synthesized; every other node's kind is memoized under the pinned node identity that the compiler already maintains for hashing and common-subexpression elimination. Because the graph is acyclic and the lattice is monotone and finite, this converges in one downward pass.

Incremental re-typing is observationally equal to a full re-inference — it is the same function, memoized — so it can never disagree with the shipped module. It is what keeps the sub-16 ms editing budget, together with incremental parsing.

#Presentation is inferred, not configured

Here is the payoff of a kind system that tracks meaning. A kind already says what a value is; so it also says how it should be shown. That is why the first program anyone writes is one line long and needs no options:

Kinds flowing bottom-up through an expression
Figure — kinds flow bottom-up; stdev is a dispersion (a vector), a literal multiple keeps its role, and point + vector = point lands the whole expression on the price axis — so it overlays.

The compiler derives a registry entry from the kind, and refines it with metadata from the operation itself:

registry := merge( reg(kind), opMeta(expression) )

reg(kind) supplies the defaults; opMeta refines them (an rsi adds its 30/70 guides). The complete default table:

Kind Mode Scale Reference lines CSS class
price overlay, on the price axis shared price scale flux-price
level own pane symmetric around 0 0 flux-level
osc(lo,hi) own pane fixed [lo,hi] midpoint, plus operation guides (rsi → 30/70) flux-osc
ratio own pane (log optional) around 1 1 flux-ratio
volume own pane signed-aware ([0,max] when non-negative) 0 flux-volume
pv own pane auto 0 flux-pv
signal marks / fills / bar colouring — never a line flux-signal
dir bar colouring / marks — never a line flux-dir
slope own pane symmetric around 0 0 flux-slope
barspan own pane (a count of bars) [0,max] 0 flux-barspan
barindex x-axis position / anchor — never a series flux-barindex
composed dimension (, P²·V⁻¹) own pane, auto-labelled by its exponents auto flux-num
angle style channel, or a pane on [-π,π] [-π,π] 0 flux-angle
depth projected on the z axis in 3-D; flattened in 2-D host z space flux-depth
time / duration / period x axis / annotation
decimal(scale) follows its dimension; values formatted to scale decimals its dimension its dimension its dimension
record{…} exploded field by field per field per field per field
vec(κ, N) reduced or indexed at the element kind κ; or rendered as a representation (a volume profile is a histogram) inherits κ inherits κ inherits κ
color, clock, string, ui consumed by their channel — never plotted
num / quantity fallback pane — you see the erasure auto flux-num
/ not presentable — [ErrPlot]

The CSS class is part of the derivation, not a detail of the theme: the host stamps it on the rendered series, so a stylesheet can restyle every oscillator on the chart without any script naming a colour. It is the one place where "the presentation is inferred" reaches all the way out to the page.

From kind to presentation
Figure — reg(κ): each kind carries its own pane, scale, guides and CSS class, and the registry is merged with the operator's own metadata. A string, a clock and a color are consumed, never plotted as a series; and are [ErrPlot]. None of it was configured.

Four rules complete the picture:

  1. Overlay if and only if the value shares the price axis. Otherwise it gets a pane.
  2. Co-plotting joins scales. Two series in one pane take the of their bounds; if that join lands on quantity, you get [WarnBranchDim] and a suggestion to split the pane. (A price overlay next to a ratio pane is not a mix — it is two panes, and it warns about nothing.)
  3. The final kind decides. level + price → price, so the expression overlays.
  4. Reference lines from convention live in opMeta, not in the kind. osc(0,100) gives a midline; that rsi conventionally marks 30 and 70 is a property of rsi.

A record explodes into its fields, each presented at its own kind — which is why plot bollinger(close, 20, 2) yields three price lines plus a band, and plot macd(close) yields a centred pane with a histogram and a signal line, with no code to say so.

#Overrides — intent beats the default

Inference gives the default; the author overrides it in the plot block, and because the system knows the kind, the override is intelligent rather than blind:

fluxm = macd(close)
plot m.macd { overlay }                  // a level FORCED onto the chart → it gets its OWN secondary axis
plot ema(close, 20) { pane }             // a price FORCED into a pane → auto-scaled, fine
plot m.hist { style: histogram, color: if m.hist > 0 then up else down }
plot rsi(close, 14) { guides: [20, 80] } // authored reference lines, kind-checked as level|osc

ich = ichimoku()                          // sourceless: it reads high/low/close itself
plot ich.chikou { offset: -26 }          // a DISPLAY shift: it moves the x position, never the value
Override Effect
{ overlay } / { pane } force the mode. Forcing a level or osc to overlay gives it a secondary axis — the system knows a shared price scale would make it invisible.
{ scale: own | shared } choose the scale. { scale: shared } on a level raises [WarnScale].
{ style: … } the render glyph — a closed, host-allowlisted set: histogram, columns, stepline, area, circles, cross (a line is the inferred default).
{ color: … } a per-bar colour expression.
{ guides: [ … ] } authored reference lines, kind-checked.
{ title }, { precision }, { width } presentation metadata.
{ offset: ±lit } a display shift along x, bounded. It moves where a value is drawn, never which data it read — causality lives at the data index, so drawing into the future is a rendering choice, not a look-ahead.

Parameter UI is derived the same way: an input(…) synthesizes its kind, and the kind gives the widget (a numeric field with a range, a source picker, a boolean, an enumeration), with title:/group:/tooltip: as optional metadata. An accessibility descriptor is derived from the kind as well, so a plotted series is announced meaningfully without the author writing a label.

#What may be presented at all

Presentation admissibility is a judgment like any other, decided per kind, and enumerated exhaustively:

Judgment Admits Rejects
[Plot] price, level, osc(·), ratio, volume, pv, signal, slope, barspan, num, quantity, angle, depth, composed dimensions, a decimal of a plottable dimension, a record (exploded), a vec of a plottable kind string, time, duration, period, barindex, dir, color, clock, variant, ui, an irreducible raw vec, , [ErrPlot]
[Mark] signal, dir everything else → [ErrArg]
[Fill] two operands of the same dimension, drawn from the price-like plotted setprice, level, volume, pv, ratio, osc, slope, num (strictly narrower than the ordered set) fill price..osc[ErrDim]; time, duration, barindex, barspan, angle — orderable but not fillable; any categorical or structural kind → [ErrArg]
[ColorBars] signal, dir, color everything else → [ErrArg]
[CmpOrd] ordered scalars of compatible dimension and identical asset tags dir (categorical), string, color, clock, record, vec, variant, ui[ErrDim]/[ErrArg]
[CmpEq] scalars, string, dir, color (bit equality); record, vec, variant (deep, na-aware) clock, ui — they are consumed, never compared → [ErrArg]

Note the deliberate asymmetry: dir is not plottable as a line and not orderable, but it is markable and is a legal bar-colouring channel. Its only presentation channels are the two that make sense for a three-valued direction.

Why enumerate instead of arguing. Because the lattice is finite by family, these tables are checked by machine — every kind, and every pair of kinds, is run through every judgment. "No kind is left without a semantics" is not a claim in a document; it is a test that fails when it stops being true.

#The error policy

An error is hard if and only if a load-bearing guarantee is violated — causality, totality, the firewall — or a dimensional impossibility appears in a demanding position. Everything else that is merely suspect is a warning with a quick-fix.

#Hard errors

Code Fires when Example
[ErrDim] the dimensional algebra has no rule, or tags disagree close + rsi(close,14) · btcUsd + btcEur
[ErrRepr] same dimension, different representation tags, no explicit conversion if c then f64Price else decPrice · duration ⊔ period
[ErrCausal] a cycle with no unit delay, a non-causal resample, an unbounded lag a feedback loop written without scan
[ErrTotal] a window, capacity or iteration bound that is not a constant, or exceeds N_max window(close, n) where n is not const
[ErrTotalRec] a cycle in the def call graph def a() = b() · def b() = a()
[ErrTotalType] a cycle in the type-reference graph record Node { next: Node }
[ErrTotalMatch] a match that does not cover every label (or _) a missing arm, or an uncovered na
[ErrFirewall] analysis reads a non-deterministic presentation value rsi(live(close), 14) · now() in analysis · unseeded rand
[ErrLen] two declared vector lengths are incompatible zipping declared capacities that cannot widen
[ErrField] a missing or unknown field bb.upprer · m with { typo: 1 }
[ErrArg] an argument's kind is not admissible at that parameter passing a color where a scalar is demanded
[ErrPlot] the value is not presentable plot time · plot someClock
[ErrUnbound] an unbound identifier or a syntax hole mid-edit — one contained diagnostic

The APP plane adds [ErrState] for an unbounded Model field, on exactly this pattern; see App plane.

#Warnings

Code Fires when
[WarnTop] an intermediate binding lands on /quantity and is never consumed
[WarnAffine] an un-normalized affine combination (high + low with no /2)
[WarnBranchDim] branches of an if, or two co-plotted series, have different dimensions → quantity
[WarnBoundsØ] two osc bounds intersect to nothing
[WarnLit] a literal outside a known bound (rsi(close,14) > 150)
[WarnScale] { scale: shared } on a kind that a shared price scale would flatten
[WarnNaNChain] a chain likely to propagate na where nz was probably meant

#What an error message is

A diagnostic is human, dimensional, actionable, and linked — never a stack trace:

price + osc — you are adding a price and a 0–100 oscillator.
  close + rsi(close, 14)
          ^^^^^^^^^^^^^^ osc(0,100), a dimensionless bounded value
  A point on the price axis and a dimensionless number have no common meaning.
  Did you mean  close + atr(14)  (a price + a displacement),
  or            close * norm(rsi(close, 14))  (scale the price by a fraction)?
  → kinds: the affine substrate

A refused repaint is explained, not merely forbidden ("this would make a past value change once the bar closes"), because the refusal is the feature.

Anti-cascade. A poisoned node propagates without re-diagnosing: one root fault produces exactly one message, and the nodes downstream of it stay quiet. A single typo does not produce forty errors.

#na at runtime

Kinds are static; na is the runtime story, and the two are designed to agree.

#See also

↑ contents

Time and state

This page specifies the temporal model of the ANALYSIS plane: what a stream is, how the past is accessed, why the future cannot be, how bounded state and iteration are written, and how the step axis of a series — its clock — is itself a first-class value. Everything else in Flux leans on the guarantees defined here: causality (a value depends only on the past), no-repaint (a value, once produced for a step, never changes), totality (every construct terminates within a compile-time bound) and byte-identity (the same program produces the same bytes on every engine, warm-up included).

Kinds (price, level, signal, …) are specified in kinds.md; this page uses them freely and annotates examples with them. The presentation-side clocks — frames and events — belong to canvas.md and app-plane.md; here the clock is the data clock, the one analysis values advance on.

#Everything is a stream

A value in the ANALYSIS plane is a stream: a value-over-steps. close is not a number; it is the whole history of closing values, one per step of the chart's clock. A constant is a degenerate stream — the same value at every step. In the charting specialization a step is a bar; nothing in the model depends on that reading.

Arithmetic is element-wise: an expression relates entire streams, and the result is again a stream.

fluxfast   = ema(close, 12)      // price — a stream: one value per step
slow   = ema(close, 26)      // price
spread = fast - slow         // level — element-wise: spread at step t = fast[t] - slow[t]

There are no indices to manage and no loops to write: you state the relation once, and it holds at every step. Under the hood the runtime is incremental — when a new step arrives, each node advances by one, reusing its own bounded state; nothing is recomputed from the beginning. The element-wise reading (whole histories) and the incremental one (one step at a time) describe the same program; the compiler owes you their equivalence.

Why this rule exists. Index-free streams are what make the rest of the page possible. Because a program never names positions, it cannot name a future position; because every operator advances step-by-step with bounded state, totality and memory bounds are properties of the language, not of the author's discipline.

#The delay operator x[n]

The only way to reach into the past is the postfix delay x[n]: the value the stream x had n steps ago.

fluxprev = close[1]              // price — the previous step's close; na on the first step
diff = close - close[1]      // price − price → level : the one-step change
up4  = close > close[4]      // signal — na on the first four steps (comparison propagates na)

Rules:

A data-dependent delay is rejected: the lag would be unbounded.

fluxk = barssince(close cross_up open)
close[k]                     // ✗ [ErrTotal] — the lag must be a compile-time constant

#There is no negative index

x[-1] — "the next value" — does not exist. Not as a discouraged form, not as a lint that a determined author can silence: there is no such form. The delay index is non-negative by the language definition, and any attempt to write a negative one is rejected.

fluxclose[-1]                    // ✗ [ErrCausal] — the future is not addressable

Why this rule exists. Causality is a compile-time property, not a style rule. In a language where x[-1] parses and merely warns, every guarantee downstream — replayability, alert trustworthiness, byte-identical re-execution — holds only for well-behaved scripts. Flux inverts the burden: the ill-behaved script is inexpressible, so the guarantees hold for every program that compiles.

#Causality is a theorem

Because the past is reachable only through bounded, non-negative delay, and the forming unit of any clock is unreadable (see @ — the clock eliminator), every ANALYSIS program satisfies, by construction:

output[t] = f(inputs[0..t])

The value at step t is a function of the inputs up to and including step t — never of anything later. Two consequences follow.

Every feedback cycle passes a unit delay. A definition may depend on its own previous value (that is what running state is), but never on its own current value — a cycle with no delay has no causal reading and is rejected:

fluxema20 = 0.1 * close + 0.9 * ema20                       // ✗ [ErrCausal] — cycle with no unit delay
ema20 = scan(close, (prev) -> 0.1 * close + 0.9 * prev) // ✓ the delay is built into scan

scan (below) is the sanctioned way to close a feedback loop: the combinator hands you the previous step's output, so the unit delay is part of its meaning rather than something you remember to insert.

No-repaint. Since output[t] depends only on inputs[0..t], and inputs are append-only, a value once produced can never be contradicted by later data. This is the no-repaint guarantee: a value, once produced for a step, never changes. History is immutable — the chart you scroll back to is exactly the chart that was computed live, the alert that fired is exactly the alert a re-execution fires. Repaint is not "detected" or "warned about"; it is absent from the vocabulary. The full family of rejections shares one diagnostic: [ErrCausal] — negative delay, a non-causal resample, a feedback cycle with no unit delay, an unbounded lag.

#Warm-up and na

Most kernels need history before they can answer: a 14-step RSI has nothing honest to say at step 3. Until enough data has arrived, a kernel's output is na — the absent value. This is its natural warm-up, and each kernel inherits exactly the warm-up of its definition; the language imposes no blanket "na until N" policy on top. Byte-identity holds from the very first step: a Flux kernel and the host's native implementation of the same kernel agree on every byte, warm-up included (invariant I6).

na inhabits every kind (na : ∀κ.κ) and propagates through arithmetic with the kind preserved. Its comparison semantics are strict:

fluxr     = rsi(close, 14)       // osc(0,100) — na on steps 0..13
warm  = is_na(r)             // signal — 1 during warm-up
bad   = r == na              // na, always — never true; use is_na
assert r <= 100              // passes from step 0: na <= 100 is na, and an na verdict is PASS

The assert verdict on na is deliberately pass: an assertion fires only on a signal that is definitively false, so warm-up cannot produce spurious failures. Where absence itself must fail, write assert is_some(r) and r <= 100.

Substitution of a default uses nz(x, d), or its operator form x ?? d (they are the same construct):

fluxo  = nz(obv(), 0)            // volume — 0 is a literal and adopts the slot's kind
f  = close ?? sma(close, 5)  // price — x ?? d ≡ nz(x, d); right-associative

Both operands must agree dimensionally (the result kind is their join); same dimension with different representation tags — f64 against decimal, machine time against calendar time — is [ErrRepr], asking for an explicit conversion.

Absorption vs propagation. Arithmetic propagates na. Three pointwise operators absorb it instead — math.max, math.min, nz — so math.max(x, na) = x: a missing operand does not poison a running extreme. Window reducers do not inherit that absorption: highest(x, 20) over a window containing a hole yields na, exactly as sma, sum and stdev do. The asymmetry is pinned by byte-identity to the host kernels: the window reducers follow the native oracle, and a hand-written fold with max (which skips na by absorption) is a different — legitimate — program, not a faster spelling of highest.

One representation note: na is a single pinned bit pattern at every storage and hashing boundary, so that byte-identity and replay verification hold across engines; the semantics above are unaffected. Details live in internals/compiler-and-runtime.md.

#Windows and bounded iteration

window(x, n) materializes the last n values of a stream as a vector:

fluxw = window(close, 20)        // vec(price, 20) — the last 20 closes, at every step

The capacity n is const-folded — a literal, or an input with a bounded range (the compiler reserves the worst case). A non-constant or oversized capacity is rejected:

fluxlen = barssince(close cross_up open)
window(close, len)           // ✗ [ErrTotal] — capacity must be a compile-time constant

fold and map over a window are the total for-loop: they visit exactly the window's capacity, no more, and they terminate by construction.

fluxw    = window(close, 20)
hi   = w.fold(na, (acc, x) -> math.max(acc, x))   // price — max absorbs na during warm-up
devs = w.map((x) -> x - sma(close, 20))      // vec(level, 20) — element-wise, kind-tracked

x[1] shifts a stream by one step; window(x,4) slides a capacity-4 vector, both na-padded during warm-up
Figure — the delay x[1] and the window window(x, 4) on one series: the past that does not exist yet reads as na.

#Why there is no filter (and no flatMap)

fluxw.filter((x) -> x > 0)       // ✗ — there is no filter: the result length would depend on data

A filter's output length depends on the data; a flatMap multiplies lengths by a data-dependent factor. Neither has a compile-time capacity, and capacity is what carries the totality and memory guarantees — every collection in Flux is a vec<κ>[n] whose n is a bound the compiler can charge against its budget. Selection therefore never shrinks; it masks:

fluxw     = window(close, 50)                          // vec<price>[50]
above = vec.where(w, (x) -> x > sma(close, 50))    // vec<price>[50] — na holes, no shrinking

Masked vectors compose with na-aware iteration: an na element produces nothing. Folds and reducers see the hole and apply their own na policy; the view/canvas comprehension skips it outright — a masked-out element draws no child:

fluxgroup { for lvl in window(close, 5) -> dot { at:(bar.i, lvl) } }   // na elements: no dot

Why this rule exists. "Bounded memory" is only a theorem if no operation can grow a collection past its declared capacity or make its size a runtime surprise. Masks keep the shape static and move the "how many survived" question into the values (na holes, a count where one is needed) — which is exactly the information a total program can carry.

#Running state: scan

scan(seed, (prev) -> e) is the running accumulator — the language's feedback construct. At the first step, prev is the seed (evaluated at that step); at every later step, prev is the value the scan itself produced at the previous step. The emitted value is the state. The unit delay that causality demands is inside the combinator: prev is always one step old, so a scan can never read its own current output.

scan unrolled over four steps: each step reads the previous state through a unit delay and the current bar, and emits a final value
Figure — scan unrolled: the feedback edge always crosses a unit delay, and each emitted value is final.

The canonical example — an exponential moving average built from first principles:

fluxdef ema0(s, n) =
  let a = 2 / (n + 1) in
  scan(s, (prev) -> a * s + (1 - a) * prev)   // α → α : Σλ=1 affine step, kind-preserving

A running extreme is a one-liner:

fluxpeak = scan(high, (prev) -> math.max(prev, high))  // price — running maximum since the first step

#Composite state: a record seed

State rarely stays scalar. Seed a scan with a record and the whole record is the accumulator; the kind system tracks every field.

fluxdd = scan({ peak: close, draw: 0 }, (prev) ->
  let p = math.max(prev.peak, close) in
  { peak: p, draw: (p - close) / p })         // record{peak: price, draw: ratio}
plot dd.draw                                  // (p − close) : level ; level ÷ price → ratio

A trailing-stop with a flip — the SuperTrend family — is a record of a price and a dir ({-1, 0, +1}, compared with ==, never matched):

fluxdef flip(mult) =
  let band = mult * atr(14) in                // num × level → level
  scan({ stop: close - band, side: 1 }, (prev) ->
    if prev.side == 1 then
      if close < prev.stop then { stop: close + band, side: -1 }
      else { stop: math.max(prev.stop, close - band), side: 1 }     // ratchet: never loosens
    else
      if close > prev.stop then { stop: close - band, side: 1 }
      else { stop: math.min(prev.stop, close + band), side: -1 })

#State machines: a variant seed and match

When state is a mode, seed the scan with a variant and step it with match — the eliminator forces every mode to be handled ([ErrTotalMatch] otherwise), so a state machine cannot silently forget a case:

fluxvariant Phase { Flat | Long(entry: price) }

def step(prev) = match prev {
  Flat    -> if close cross_up ema(close, 50) then Phase.Long(entry: close) else Phase.Flat
  Long(e) -> if (e - close) / e > 0.05 then Phase.Flat else prev
}

pos = scan(Phase.Flat, (prev) -> step(prev))

Look at the exit rule, because the kind system wrote it. The obvious first draft is close < e * 0.95 — and it is rejected: e is a price, an affine point, and scaling a point by a scalar has no meaning (5% of "the 42nd parallel" is not a place). The algebra erases the dimension, and comparing the result back against a price is [ErrDim]. What you actually meant is a displacement measured against the entry(e - close) / e, a price − price over a price, which is a ratio, and a ratio compares with 0.05 happily. The compiler did not merely refuse the first draft: it named the second.

#Worked sketch: a point-and-figure column

Price-driven representations keep a column state: which way the column runs, its running extreme, how many boxes it has filled. As scan state:

flux// state : record{ dir: dir, extreme: price, count: num }
def pnfCol(box, rev) =                        // box : level (const-folded), rev : num
  scan({ dir: 1, extreme: close, count: 0 }, (prev) ->
    if prev.dir == 1 and close >= prev.extreme + box then
      prev with { extreme: prev.extreme + box, count: prev.count + 1 }
    else if prev.dir == 1 and close <= prev.extreme - rev * box then
      { dir: -1, extreme: prev.extreme - rev * box, count: rev }
    else prev)                                // sketch: up-side only, one box per step

The kinds are the point of the sketch. count is a pure number of boxes — dimensionless — while box : level carries the price dimension. So:

Store count : num and the algebra reconstructs every geometric quantity with the right kind; store a price per box and the arithmetic would type as nonsense (price + price has no affine meaning). The production version of this sketch is not a scan at all but a change of clock — pnf(box, rev) in Clocks — yet its internal state types by exactly this reasoning.

#stateful — the low-level escape hatch

stateful(seed, (st, bar) -> e) exposes the engine's recursive primitive directly: you receive the previous state and the current step's raw data, and return the next state. It is the same construct as scan with the sugar removed, for the rare kernel whose step cannot be phrased as an expression over named streams. Prefer scan; reach for stateful when you need the whole bar record at once.

fluxacc = stateful({ n: 0, ups: 0 }, (st, bar) ->        // seed is a RECORD; bar is the whole step
  { n: st.n + 1, ups: st.ups + (if bar.close > bar.open then 1 else 0) })

#loop — bounded iteration within a step

loop(max, init, step) iterates within a single step: max rounds (const-folded), starting from init and applying step to the running value. It is the total replacement for a while: the bound is part of the program, so termination is a fact, not a hope. Root-finders, implied-value solvers and smoothing passes are its natural users.

fluxdef nroot(x) =                                // num → num : Newton, a fixed 24 rounds
  loop(24, x / 2, (g) -> (g + x / g) / 2)

plot nroot(rsi(close, 14))                    // dimensionless in, dimensionless out

Totality comes from max, and from max alone. It is a static ceiling: the compiler budgets the worst case, so the cost of a step is known before the step runs, whatever the data does. In this shipped form the ceiling is also the exact count — loop runs max rounds and takes the last value — which in practice costs nothing here, because Newton doubles its correct digits each round: an f64 root has converged long before round 24, and the remaining rounds are fixed points.

Post-v1. The sealed design carries a fourth argument, an early-exit predicate: loop(max, init, step, until) stops as soon as until holds. That does not weaken totality — the budget is still max, and the compiler still reserves it — it only lets a converged iteration stop paying for rounds it does not need. The shipped signature does not take it yet, so the form above is the one that runs today.

No unbounded loop exists in the v1 surface: there is no token for one, and every practical iterative kernel states its budget. (Open decision. The design discusses an opt-in unsafe escape for an unbounded loop and discourages it by default; nothing in the catalogue needs it.)

#Cumulative and anchored streams

Two combinators cover "since forever" and "since an event":

fluxtotal = cum(volume)                                        // volume — since the first step
sess  = in_session("09:30-16:00 America/New_York")         // signal — 1 while the session is open
sVol  = cumSince(sess, volume)                             // volume — since the session opened

The anchored VWAP is this idiom applied to a ratio of accumulations — and it types because the dimensional algebra divides the dimensions out:

fluxsess  = in_session("09:30-16:00 America/New_York")                // signal — the anchor
avwap = cumSince(sess, close * volume) / cumSince(sess, volume)   // pv ÷ volume → price
avwap = vwap(anchor: sess)                                        // the packaged overload

Any signal can anchor: a session open, a crossover, a structural break. An input of kind price or time may be placed by click on the chart — the host writes the chosen value back as a pinned input, so the analysis still reads an input, never the pointer, and a "VWAP since the step I clicked" stays fully causal and replayable.

#Clocks: the step axis is a value

Every series advances on a clock, and clock is a first-class kind: an ordinal step index plus a mapping between step indices and time. A clock answers two questions — what is step i's timestamp? and which step contains time t? — and nothing else; position on the axis is always the ordinal index, never wall-time.

Four constructors build clocks:

constructor steps advance on example
tf("1h") closed time buckets hours, days, weeks
renko(box) price moving one box : level bricks
pnf(box, rev) price filling boxes, reversing after rev point-and-figure columns
range(r) price traversing a range r range bars

Time-coarse clocks and price-driven clocks are the same concept: a rule for when a unit closes. This is why alternative chart representations are not renderers bolted onto bar data — a Renko or point-and-figure series is the ordinary series machinery running on a different clock — and why multi-resolution analysis is not a special feature: an expression at another resolution is an expression on another clock, resampled back (next section).

Clocks are ordinary values. They flow through if, through def parameters, through input:

fluxregime = adx(14).adx > 25                     // signal — a trending regime
c      = if regime then tf("1d") else tf("4h") // clock — chosen like any other value

Constructor parameters const-fold: renko(50) (the literal adopts kind level), or a bounded input. A data-dependent box is rejected — a clock is a fixed axis, not a quantity that drifts with the data it is supposed to index.

Functions may consume clocks like any value: barsPerYear(clk) → num derives the periods-per-year of a time clock (for annualization); on an event clock (renko, pnf) the question has no answer and it returns na with a diagnostic.

#@ — the clock eliminator

e @ c evaluates e per unit of the clock c and reads the result back on the current series' steps. It is the eliminator of the clock kind — clocks are outside arithmetic, and @ is the one operation that consumes them. Resampling is kind-preserving (resample : (α, clock) → α) and causal: at any step, e @ c reads the value of e at the last closed unit of c — never the forming one.

fluxc   = tf("4h")                                // clock — a first-class value
d   = ema(close, 20) @ "1d"                   // price — the daily EMA, on the chart's steps
r   = rsi(close, 14) @ tf("1h")               // osc(0,100) — kind-preserving
x   = close @ c                               // any clock value works as the operand

The operand may be a string literal (shorthand for the time-coarse clock it names), an identifier or a call (tf("1h"), renko(box)), or a parenthesized expression. @ is a postfix operator and binds tighter than comparison, which makes the confluence idiom read naturally:

fluxplot close > ema(close, 20) @ "1d"            // signal — close vs the last CLOSED daily EMA

parses as close > (ema(close, 20) @ "1d"): the current step's close against the daily average as of the most recent completed day.

Two timelines: 5-minute bars read the last closed 1-hour unit; the forming hour is greyed and never read
Figure — e @ tf("1h"): every fine step reads the last closed coarser unit; the forming unit is invisible to analysis, and steps before the first closed unit read na.

#The locator is floor-containing

Mapping a step's time t to a unit of the coarser clock uses the floor-containing rule: the unit whose span contains t — the most recent one whose start is Tₖ ≤ t, never a round-to-nearest match. Reading it then follows the closed-unit rule above: a step inside a still-forming unit takes the last unit already closed.

Why this rule exists. Round-to-nearest maps a step in the first half of a forming unit to that forming unit — a value that will still change — which is a look-ahead: the analysis would read data that did not exist at the step being computed, and history would repaint when the unit closes. Floor-containing is the unique alignment under which output[t] = f(inputs[0..t]) survives resampling.

Before the first closed unit of the coarser clock, e @ c is na — resampling inherits warm-up like everything else, and byte-identity holds through it.

#The clock contract

The resampling machinery is pinned by seven invariants; they are the reference semantics of @ and of clocks generally:

invariant statement
I1 Position is ordinal — a series is addressed by step index; wall-time never enters the axis.
I2 The resample locator is floor-containing — never round-to-nearest.
I3 Only closed units are readable; the forming unit is invisible to ANALYSIS.
I4 The time grid is the clock's real one, not an idealized uniform grid.
I5 One clock per series (v1) — clock composition is rejected.
I6 Byte-identity, warm-up included: a resampled kernel matches the native one from the first step.
I7 The interpreter and the compiled engine produce identical bytes, verified at every compilation.

I5 is the honest v1 limit: a series has exactly one clock, so a clock cannot be resampled onto another clock —

fluxclose @ renko(50) @ "1d"                      // ✗ — one clock per series in v1: no clock composition

is rejected. Stacked re-bucketing (price bricks, then daily aggregation of bricks) is a coherent concept and deliberately out of v1's sealed scope.

#Foreign series and the as-of rule

Resampling is one instance of a general problem: aligning a series that closes on its own schedule onto the chart's ordinal axis. Another asset, another venue, another session calendar — their steps do not coincide with the chart's. The alignment rule is the as-of join, and it is the same floor-containing rule as @:

At chart step t, a foreign series reads its most recent step with timestamp t's time.

Never round-to-nearest — that would peek at a foreign step that had not happened yet. During a foreign gap (its market closed, the chart's open), the value holds: the last known foreign value is still the most recent one. Before the first foreign step, the value is na — ordinary warm-up.

fluxeth = series("ETH-USD").close                 // price[ETH,USD] — aligned as-of, holds over gaps
rel = close / eth                             // ratio — cross-rate (same quote), plottable

Because the as-of join admits no future row, the no-repaint property of the composite is inherited outright: a cross-series indicator is exactly as replayable as a single-series one. The kind system separately guards what you may combine — asset and currency tags on price make price[BTC,USD] + price[ETH,USD] an [ErrDim] — see fdk/asset-currency.md.

#Points, durations, periods

Three kinds carry time itself, and the affine discipline of the price axis applies verbatim to the time axis:

kind role representation
time a point on the timeline machine instant (64-bit epoch)
duration a vector of elapsed machine time exact — machine tag
period a vector of calendar time zone/DST-aware — calendar tag

duration and period are the same dimensional object — a displacement on the time axis — distinguished by a representation tag, exactly as f64 and decimal tag the same numeric dimension. Both additions are point + vector → point:

fluxage    = time - time[1]                       // duration — pt(T) − pt(T) → vec(T), exact
expiry = time + time.months(3)                // time — calendar arithmetic, DST-aware
later  = time + time.months(1) + time.days(10)   // period constructors compose

period values are built only by the constructors time.years(n), time.months(n), time.weeks(n), time.days(n) (const arguments). The two representations never mix implicitly:

fluxtime.days(1) + (time - time[1])               // ✗ [ErrRepr] — calendar + machine: convert explicitly

Why this rule exists. "One day" and "24 hours" are different claims: across a daylight saving transition, adding time.days(1) lands on the same wall-clock time the next civil day, while adding a 24-hour duration lands an hour off. Both are useful; silently conflating them is how session logic breaks twice a year. The tag keeps each addition honest and makes the conversion a visible decision.

Calendar accessors — year, month, day, hour, minute, second, dayOfWeek, dayOfYear — project a time into a declared zone and return num. The default zone is a pinned replay input: since an accessor runs in the ANALYSIS plane, its zone cannot be an ambient per-viewer setting, or two machines would compute different values for the same script; where no pinned default applies, the zone is written explicitly, as in_session already does. Arithmetic that leaves the representable range does not wrap: it yields na with a diagnostic, preserving the causal order of timestamps.

#live(e) — the forming unit, display only

Everything above concerns confirmed values: streams that advance when a unit closes. Displays legitimately want one more thing — the unit still forming. live(e) provides it, as a presentation signal:

Any confirmed sink is barred by the firewall — the one-way boundary between presentation and analysis (see book/04-the-four-planes.md):

fluxplot live(ema(close, 20))                     // ✓ display — the forming step included, per frame
h1  = high @ live(tf("1h"))                   // ✓ display — the forming hour's running high

rsi(live(close), 14)                          // ✗ [ErrFirewall] — feeding an analysis calculation
alert live(close) > sma(close, 20)            // ✗ [ErrFirewall] — alerts are confirmed sinks
assert live(close) > 0                        // ✗ [ErrFirewall] — assertions run on confirmed data

The confirmed stream of e is untouched — byte-identical with or without live anywhere in the script — and live() values are excluded from the byte-identity oracle exactly as wall-clock signals are. A script that uses live() is flagged non-replayable: its display cannot be reproduced from the journal, because the forming data it painted was never committed. Its analysis remains replayable; the flag is about the pixels.

Why this rule exists. The forming unit is the one value in the system that will change. Letting it into a calculation would manufacture exactly the repaint the language exists to exclude — an indicator that looks prescient live and rewrites itself at the close. Routing it to display sinks only gives the legitimate use (watching the current unit develop) with zero effect on any confirmed value, any alert, any replay.

#Session, event and pivot helpers

The small vocabulary that connects streams to calendars and events:

helper kind meaning
in_session(spec) signal 1 while the named session is open; the spec names hours, zone and the asset calendar
barssince(s) barspan steps elapsed since s last fired
valuewhen(s, x) kind of x the value x had when s last fired
count(s, n) osc(0,n) how many of the last n steps fired s
rising(x, n) / falling(x, n) signal monotone over the last n steps
a cross_up b / a cross_down b signal crossing, as an infix comparison

barssince returns barspan, not a bare number: counts of steps carry the ordinal dimension (the x-axis is affine too), so a bar count cannot be silently added to a price — while barindex − barindex → barspan and slope-like quantities (price per barspan) fall out of the algebra with correct kinds.

fluxsess  = in_session("09:30-16:00 America/New_York")   // signal
sinceX = barssince(close cross_up ema(close, 50))    // barspan
atX    = valuewhen(close cross_up ema(close, 50), close)   // price — held until the next firing

#Pivot confirmation: latency in the signature

A pivot — a local extremum — is only knowable in hindsight: a high is not a pivot high until enough later steps have failed to exceed it. Flux makes that hindsight explicit. Pivot detectors return signal @lag right: the signal fires on the step where confirmation completes, right steps after the extremum, and the @lag annotation is part of the frozen signature — the latency is documented, bounded and visible to the reader, not an implementation surprise. The pivot's value is retrieved with valuewhen:

fluxph  = pivot_high(high, 3, 3)                  // signal @lag 3 — confirmed 3 steps after the top
lvl = valuewhen(ph, high)                     // price — the last confirmed pivot level

The familiar alternative — a zigzag whose last leg mutates until the next pivot confirms — is inexpressible, and deliberately so: a mutating last value means a produced value changed (repaint, excluded by the causality theorem), and its confirmation latency is unbounded (excluded by totality). The causal decomposition is: confirmed pivots as analysis values (above), and — where a forming leg is wanted on screen — a provisional presentation marker fed by live(), which never becomes an analysis value. What cannot be written is precisely the part that would have lied.

#See also

↑ contents

The CANVAS plane

The canvas plane is where a program shows things. It runs on the frame, it is allowed to use screen space, wall-clock time and randomness, and it may read everything the analysis plane computed — while being structurally unable to write back into it.

It has one axiom, and the axiom is the whole design:

Every property is a signal. A constant, a data value and an animation are the same kind of thing. There is therefore no animation API — animating a property means giving it a signal that varies, exactly as plotting a value means giving it one that does.

fluxdot { at: (bar.i, close), r: 4 }                    // a constant radius
dot { at: (bar.i, close), r: 2 + norm(volume) * 8 } // a data-driven radius
dot { at: (bar.i, close), r: tween(2 -> 10, 400ms) }// an animated radius

Three programs, one property model, no framework.

A note on the samples. Flux has no expression-statements, so a bare expression is not a program. The lines marked on this page are therefore expression fragments: they exist to show what the kind rules refuse, not what the parser accepts. Every unmarked line is a legal statement.

#The four axes

A canvas program is organized along four orthogonal axes: spaces (where a coordinate lives), signals (what a property is), events → actions (what interaction does), and composition (how elements are grouped and repeated).

#1. Spaces — the coordinate derives its axis from its kind

You write The axis it lands on
price the price axis
time, bar.i (barindex) the time / ordinal x axis
screen.cx, screen.w, … viewport pixels
pane, ratio a sub-pane fraction
z (depth) the depth axis — projected in 3-D, flattened in 2-D

Mixing two spaces inside one coordinate is [ErrDim] at compile time. A geometrically incoherent scene is not expressible.

fluxdot  { at: (bar.i, close), r: 4 }                    // the time axis × the price axis
line { at: (bar.i, ema(close, 200)), w: screen.w, stroke: token.grid }
dot  { at: (bar.i, close + volume) }                 // ✗ [ErrDim] — price + volume: two axes, one coordinate

The exception that everyone needs — pinning a label eight pixels above a candle — is the composite anchor. Written high + 8px inside a coordinate, it is a two-component coordinate constructor — a data anchor plus a pixel offset — and not an arithmetic sum across sorts. The two components keep their own axes and are never added to one another; the + is the constructor's notation, not the operator.

The pixel part must be a const. A screen-derived offset (screen.h * 0.05) would make the whole position screen-dependent, and the position is one of the things that must stay deterministic. display states the same rule from the renderer's side.

#2. Signals — the generators and the combinators

Family Members
Generators tween(a -> b, d, ease) · sweep(a -> b, d) · wave(sin|tri|saw, amp, T) · spring(target) · noise(seed, v) · ramp · throb
Combinators mix · lerp · clamp · norm · stagger · since · hold · pick · rand
Host facts now() / clock:wall · screen.* · bar.isLast · chart.lastBar
The forming-bar reader live(e) — display sinks only

Post-v1. The generator spring ships in its single-argument form spring(target); an optional stiffness spring(target, k) is designed and deferred.

Every one of the "host facts" is canvas-only: reading it from analysis raises [ErrFirewall]. That includes randomness — with one deliberate carve-out:

fluxjitter = rand(1337)   // ✓ legal in analysis: a pinned integer generator, replayable, byte-identical
rand()                // ✗ [ErrFirewall] in analysis — unseeded randomness is not replayable

Seeded randomness is deterministic and therefore admissible anywhere. Unseeded randomness is a presentation signal, and it stays on the presentation side of the wall.

#3. Events → actions

fluxema50 = ema(close, 50)
on click                    -> burst(40) ring { life: 2s }
on every(1 bar)             -> spawn ring { r: 6 -> 24, opacity: 100% -> 0% }
on close cross_up ema50     -> flash
on switch(asset)            -> morph chart over 500ms

The event operand may be a boolean stream from analysis (close cross_up ema50), a timer (every(1 bar), every(300ms), every(~2s) for a jittered period), or a pointer event (hover, click, drag, enter, exit, move, wheel).

The action may be a spawn (spawn, burst(n), emit rate(r) — all drawn from a capped pool with a life:), a tween of a property, a bounded effect (flash, bounce, pulse, shake), or a set.

Interaction on this plane stays cosmetic: it may spawn, tween, flash, and set presentation properties. It cannot change a computed value, and it cannot persist anything. When you need state that survives an event and decides what is displayed, you have crossed into the APP plane — and the language makes you say so.

#4. Composition

fluxgroup  { dot { at: (bar.i, close), r: 3 } }             // transform / blend / clip a subtree
repeat 8 as i { dot { at: (bar.i, close), r: 2 + i } }  // instancing — `i` parameterizes the shape
for lvl in window(close, 5) -> dot { at: (bar.i, lvl) } // a comprehension over a BOUNDED collection

repeat and for are the two ways to make many things. Both are bounded by construction — a const count, or a collection whose capacity is declared — which is what makes the instance budget computable at compile time rather than discovered at 3 a.m. in production.

To iterate over the indices 0 … N−1 rather than over data, take them as a collection: vec.range(N) is the vector of those indices, and the comprehension consumes it like any other.

fluxstep = atr(14)
for i in vec.range(5) -> dot { at: (bar.i, close + i * step), r: 3 }

The rule to remember is not that indices are unavailable — it is that the count must be const. vec.range takes a literal, exactly as window and repeat do. A data-dependent count would make the instance budget data-dependent, and the budget is precisely the thing that has to be known before the first frame is drawn.

#The primitives

The set is closed and vetted — a script cannot invent a primitive, and therefore cannot smuggle markup, a URL or a raw byte buffer into the renderer:

dot · circle · ring · rect · square · triangle · poly · line · path
text · image · svg · sparkline · backdrop

They share one property model: at, size / r / w / h, rotate, fill, stroke, width, opacity, glow, blend, z, life, color, trail, paintOrder. The model is the whole vocabulary — there is no per-primitive dialect on top of it. A line is positioned with at and sized with w / h, exactly as a dot is; it has no endpoint properties of its own.

A scene can also be a value:

fluxdef overlayOf(m) = scene {
  line { at: (bar.i, m.anchor), w: screen.w, stroke: token.grid, width: 2 }
  for lvl in m.levels -> dot { at: (bar.i, lvl), r: 3, opacity: 60% }
}

scene{…} has kind ui, so it can be returned from a function and handed to a window — which is how a drawing overlay reaches a chart pane without the canvas plane and the app plane having to know about each other.

#The performance model

You never write a render loop, and you never optimize one. The scene compiles once, and the compiler classifies every signal and routes it:

Class Example Route Cost per frame
static stroke: token.grid cached — never recomputed 0
per-bar at: (bar.i, ema(close, 20)) pre-allocated buffers; identical shapes instanced O(Δ bars)
per-frame, time-only glow: throb(0.4) the host compositor 0 JavaScript per frame

That third row is the one that matters. The properties that move the most — a glow, a pulse, a parallax — are exactly the ones that cost nothing, because they never touch the language's runtime at all.

Emitters (spawn, burst, emit rate) draw from a capped pool with a mandatory life:, so a particle storm has a compile-time ceiling. Exceeding a scene budget — draw-list operations, instances, or worst-case GPU work — is [ErrSceneBudget] at compile time. There is no runtime out-of-memory, and no device reset.

#What canvas may and may not do

May May not
read any analysis value write any analysis value
use now(), screen.*, unseeded rand let any of them reach a decision
read the forming bar through live() feed live() into an alert, an assertion, or a calculation
spawn, tween, flash, set persist, enable another script, reconfigure the app
toggle the visibility of its own output touch anything else's

Everything in the right-hand column raises a compile error with an explanation — usually [ErrFirewall], and usually with the suggestion of the plane you actually wanted.

#See also

↑ contents

The TRANSITION plane

A transition interpolates the rendering between two states that have already been computed. That sentence is the entire safety argument: if both endpoints are values the analysis plane produced, then interpolating between them cannot produce a new value — so a transition is cosmetic by definition, and no animation, however elaborate, can repaint a chart.

The plane exists because the alternative is worse. Animation bolted onto a rendering layer ends up reading state it should not read, and re-entering computations it should not re-enter. Giving it its own plane, with its own clock and its own rule, keeps the guarantee where it belongs.

#What a script can do here

Five powers, and no more:

flux// ① parameterize a built-in morph
on switch(asset) -> morph chart over 500ms { ease: inOutCubic ; stagger: 0.3 ; surplus: collapse }

// ② carry the transition of a custom representation (the `morph:` hook of its descriptor — post-v1)

// ③ trigger a bounded effect on a signal
on close cross_up ema(close, 50) -> flash

// ④ animate the view
on click -> focus(view, at: (bar.i, close), zoom: 2.0, over: 600ms, ease: outBack(1.2))

// ⑤ replay history from the bar where a signal fired
replay from close cross_up ema(close, 200) over 8s

switch(asset) is a host event, not an analysis symbol: the current asset is a fact the host owns, and the firewall forbids analysis from reading it. It is delivered like any other presentation edge (hover, click, enter), which is why a transition can respond to it while an indicator cannot.

Post-v1. Power ② — the per-representation morph: hook — is designed, but it is not yet an author key: the morph controller is single-type (candle), and the descriptor the hook would hang from is reified in the same rollout. Until then a custom representation animates through the built-in morph (①), which is the path every built-in representation already takes.

replay from takes a signal, not a bar index. The replay begins at the bar where the condition fires and runs forward over the declared duration, which is what lets you address a replay by what happened rather than by how far back it was. The start point is therefore an analysis value the oracle already contains — the transition plane discovers it, it never computes it.

#The settle is in the oracle; the trajectory is not

This is the distinction that makes the plane work, and it is worth being exact about:

In the byte-identity oracle? Why
the settle value — where the transition lands yes it is analysis data (or a Model value); it is what the scene is once the animation ends
the trajectory — how it gets there, t ∈ (0,1) no it is per-frame, device-dependent, and observable by nobody but the eye

Two consequences follow directly.

prefers-reduced-motion changes nothing that matters. The host applies it at the compositor: it jumps to the settle state. Since the settle is in the oracle and the trajectory is not, the verdict — the numbers, the golden, the replay — is identical either way.

A transition never leaks its progress into a decision. The plane exposes only the terminal edge (Done), never the continuous progress; and that edge is scheduled at a deterministic journal rank derived from the declared duration, not at the wall-clock moment the animation happened to finish. Two clients, one animating and one with motion reduced, journal the same edge at the same rank. This is the [TransSettle] invariant; see display.

Why the progress is withheld. If a script could read t = 0.63 mid-animation and store it in a model, then the model — and any verdict computed from it — would depend on the frame rate, the device, and the GPU's mood. Withholding the trajectory is not a limitation of the animation; it is what makes the animation free.

#The transition descriptor

Built-ins and scripts converge on one descriptor, so a custom representation animates exactly the way a candle does:

Field Meaning
durationMs how long
easing the curve — a value of the ease sort, opaque and host-vetted
wave, staggerSpread the shape of the stagger across elements
wickLead the lead-in of the thin parts, before the bodies
surplusPolicy collapse | spawn | hold — what happens to elements that have no counterpart on the other side
chromeFadeFrac, holdDeadlineMs, flipTiming the cosmetic timings around the morph

Per-call overrides (over D, stagger, surplus:) refine it at the point of use.

ease is a sort, not a variant — you can pass inOutCubic or cubicBezier(a,b,c,d) around, but you cannot match on a curve and take it apart. That opacity is deliberate: a decomposable curve would let a script re-parameterize time in a data-dependent way, and the boundedness of the animation would go with it.

#The boundary with the native engine

The heavy per-candle morph stays native — it is a hot path, and it is not the language's job to re-implement it. Flux orchestrates: it fills in the descriptor once, and the native controller runs it.

That split is why there is no performance cliff when you animate: the script does not run per frame, and the thing that does is the code that was already there.

#What a transition may not do

May May not
interpolate the rendering between two computed states compute a state
read analysis values (to know where to land) write an analysis value
respond to a host edge (switch(asset), a click) read the wall clock into a Model
expose its terminal edge expose its progress

#See also

↑ contents

The APP plane — applications

Post-v1. The APP plane is fully designed and strictly additive to the frozen core; its rollout follows the v1 language. Everything below is normative for what an application is.

The first three planes compute, present, and interpolate. None of them can hold state that persists between events and decides what is displayed — a score, a document, a selection, a blotter of open positions. That single missing primitive is what the APP plane adds: a reducible model, under a recipe that keeps every guarantee the other planes rely on.

An application is a model, a pure update, a pure view, and a set of declarative subscriptions. Effects are inert data the host executes; capabilities are default-deny; the message journal is the single source of truth, so an application can be replayed message by message, tested without a single mock, and re-executed by a server bit-for-bit.

The application loop
Figure — everything ambient enters through the front door as a message; everything outgoing leaves as inert data the host interprets under a capability.

A note on the samples. Several samples below carry a member of an app block — an update, a view, a subs — outside its block. A member is only legal inside one, so read those samples as if the member sat within app name { … }; the type and def declarations beside it are ordinary top-level statements. Every other sample on this page is a complete program.

#Undo, redo and time travel come free

In most codebases, undo/redo is a feature you build again in every application: a stack of inverse operations, maintained by hand, and subtly wrong at the edges — the undo that forgets one field, the redo that brings back a selection you had already moved on from. In Flux you do not build it at all. It is a property of the architecture, because an application's state never changes except through one deterministic, journaled reducer.

The state is a fold: the model is fold(init, [the journal of every message so far]). Undo rewinds the journal one step and folds again; redo re-extends it. Because the fold is a pure function of the journal, the state you land on is exactly the state you left — reproduced, not reconstructed.

fluxvariant Msg { Bump | Undo | Redo }

app tally {
  capabilities: [ journal ]

  init(p)        = { doc: { n: 0 }, ui: { pending: na } }
  update(m, msg) = match msg {
                     Bump -> { model: m with { doc: m.doc with { n: m.doc.n + 1 } }, cmds: [] }
                     Undo -> { model: m, cmds: [ Journal(UndoToMark) ] }
                     Redo -> { model: m, cmds: [ Journal(RedoToMark) ] }
                   }
  view(m)        = row {
                     button("undo", Undo)
                     text("{m.doc.n}")
                     button("+1", Bump)
                     button("redo", Redo)
                   }
  subs(m)        = []
}

The two arms that give this application its entire undo history are Journal(UndoToMark) and Journal(RedoToMark). They return the model unchanged and hand the host an inert command; the host — which is where the journal lives — truncates it to the target and re-folds (init, [msg]') into the new model. Nothing in the application code knows how to reverse an edit. It only knows how to move forward.

One mechanism, two faces. For the user, this is undo/redo inside the app — an undo that cannot silently forget a field or resurrect a stale selection, in a level editor, a trading tool, a game. For the developer, the same journal is a time-travel debugger: the bar-axis cursor of the analysis debugger becomes an event cursor. Scrub to any past message, step backwards, set a data breakpoint — stop at the first message where score crosses 100 — and put the run beside a reference run to see where, if ever, they diverge.

It falls out the same way for very different applications:

Why undo is correct, and not almost-correct. Here is the subtlety hand-rolled undo nearly always gets wrong. An application's model is split in two: a doc — the business state the history owns — and a ui — the selection, the cursor, the half-drawn shape, the in-flight request — which the history does not own. Undo rewinds doc and leaves ui untouched. So it never revives a selection you made three edits ago, or a request that has since returned. The boundary is part of the design; you cannot forget to draw it, because the two live in different fields of the model.

Why redo is exact, and not approximate. Flux is deterministic to the byte — the interpreter and the compiled module agree on every bit, and na has one canonical pattern — so re-folding the journal reproduces the state byte-for-byte. That includes the answers that came back from the network: an async result entered the model as a message, so it is already in the journal, replayed verbatim rather than re-fetched. Rewinding never fires the request a second time; it reads the reply that already happened.

Two honest notes. First, this is design, not shipped code — the APP plane is sequenced after the v1 language (the label at the top of this page), and none of it runs yet. Second, it is free of bespoke work, not free of cost: an undo rebuilds the model by re-folding from the nearest memoized checkpoint, so it costs work proportional to the distance back to that checkpoint, and the machinery — a message journal, a fold, an event timeline — is real, modest, and new. It is reused by every application rather than rebuilt in each, which is the whole point. The formal contract, the cost bounds and the two named exceptions are in Totality, determinism, replay below. And note the exact claim: this is undo/redo over an application's execution — the events it processed — never over its source code.

#The shape of an application

fluxapp counter {
  capabilities: [ clock, sfx ]        // `clock` backs OnTick; `sfx` backs PlaySfx — both are needed

  init(p)        = { n: 0 }
  update(m, msg) = match msg {
                     Tick  -> { model: m with { n: m.n + 1 }, cmds: [] }
                     Reset -> { model: m with { n: 0 },       cmds: [ PlaySfx("reset") ] }
                   }
  view(m)        = row {
                     text("count: {m.n}")
                     button("reset", Reset)
                   }
  subs(m)        = [ OnTick(1000, Tick) ]
}

Both entries in that capability list are load-bearing, and the second one is easy to forget. clock is what backs OnTick; sfx is what backs PlaySfx. Drop sfx and the program stops compiling — not at the moment the sound would have played, but at the Reset arm, because emitting a command the manifest does not grant is [ErrCapDenied]. The list is not documentation of intent; it is the grant the compiler checks every cmds: against.

The app block compiles to a descriptor — a sibling of the representation and tool descriptors, with no base class:

Member Kind Role
capabilities: a list of capability references what the application requests. Default-deny: anything not listed is not merely unavailable, it is a compile error to emit.
init(params) → Model the pure initial state
update(model, msg) → record{ model: Model, cmds: vec(Cmd, N) } the pure, total, deterministic reducer
view(model) → UiTree a pure tree of vetted primitives — never raw markup
subs(model) → [Sub] declarative inputs, recomputed from each model
contributes? optional interface contributions (panes, panels, commands, tools)

The five member names are fixed keywords, not free identifiers: the roles of the harness are part of the language, so the compiler can check them.

#The Model — bounded state

Every field of a Model must have a bounded kind. Scalars qualify; so do string (immutable UTF-8 with a declared cap) and decimal(scale) (a fixed-width value type); so do records and vec(κ, N) with a const-folded N. An unbounded field — or a — is [ErrState], the Model's exact analogue of [ErrPlot].

fluxvariant Verdict { Right | Wrong }

record Model { n: num ; label: string ; history: vec(Verdict, 64) }   // bounded — ok
// a growing list with no declared cap is not a Model field: ✗ [ErrState]

The cap is a const-folded length — a literal, as above, or an identifier that const-folds to one (vec(Level, MAX_LEVELS)). Either way the compiler knows the number before the first message arrives.

Why bound the Model. Because the memory footprint of an application then becomes computable at compile time — the plane inherits the same totality argument as the analysis plane, and a running application cannot leak, thrash, or be killed mid-frame for exceeding a budget it never declared. Boundedness is not a restriction imposed on the author; it is what lets the compiler promise the budget in the first place.

#The doc / ui partition

Any application with an undo needs this, and needs it from the first line: split the Model into a doc sub-record (the business state, versioned by history) and a ui sub-record (selection, cursor, an in-flight request's epoch, a drag draft) that does not enter history.

Without the boundary, undoing a change would resurrect a stale selection or a dead draft. With it, undo is exactly "truncate the journal to the previous bound and re-fold".

#Functional record update

m with { … } rewrites the listed fields and carries the rest forward. It is shape-preserving: a field that does not exist is [ErrField], and a field you forget is kept, not silently lost.

fluxupdate(m, msg) = match msg {
  Score(pts) -> { model: m with { doc: m.doc with { score: m.doc.score + pts } }, cmds: [] }
  Select(h)  -> { model: m with { ui:  m.ui  with { sel: h } },                   cmds: [] }
}

#The Model's kind is a fixpoint

A field initialized to na in init does not stay at : its kind is the join, field by field, over init and every arm of update. picked: na in init, then picked: key (a string) in one arm, gives Model.picked : string, with na remaining a legal runtime value (absence). Declaring record Model { … } up front pins the same kinds explicitly — the two routes coincide.

#The slotmap — a bounded collection with stable removal

Editors keep drawings; a trading blotter keeps positions; a tool keeps anchors. All three need a collection you can add to, remove from, and address stably — and the Model may not grow, may not re-index, and has no filter.

The official pattern is a slotmap over a bounded vector:

The slotmap pattern
Figure — removal writes a tombstone; nothing ever moves, so every handle stays valid and the memory plan stays flat.

fluxrecord Level { id: num ; price: price ; kind: Tool ; label: string ; gen: num }

record Model {
  slots:  vec(Level, MAX_LEVELS)   // a tombstone (`na`) marks a free slot
  live:   vec(signal, MAX_LEVELS)  // 1 where a slot is occupied
  count:  num
  nextId: num                      // the monotone domain-id counter (persisted)
}

def emptyDoc() = { slots: emptySlots(MAX_LEVELS), live: vec.fill(MAX_LEVELS, 0), count: 0, nextId: 1 }

// removal — functional, length-preserving, nothing shifts
def remove(m, h) =
  m with { slots: vec.setAt(m.slots, h.slot, na),
           live:  vec.setAt(m.live,  h.slot, 0),
           count: m.count - 1 }

// iteration — `na`-aware: a tombstone produces no child at all
view(m) = col { for lvl in vec.mask(m.slots, m.live) -> levelRow(lvl) }

Four rules make it work:

Why a generation counter. Re-using a slot after a removal would let a stale handle read a new item — the classic ABA bug. Bumping gen on creation makes a stale handle read na instead. And on load, the persisted nextId is clamped upward (max(persisted, max(id)+1)), never rebased downward — otherwise deleting the highest ids and reloading would re-mint an id that history already used.

#update — pure, total, deterministic

update may not read the clock, may not read randomness, may not touch the DOM, may not read a live series inline. Everything ambient arrives as a message. It must handle every message — match exhaustiveness is [ErrTotalMatch], checked, not hoped for.

It returns a named record, never an anonymous pair: { model: …, cmds: [ … ] }. There is no tuple sort in the lattice, and adding one for this would have been a real cost for no benefit.

cmds is an ordered list of inert descriptors. A command is data: a sound's name, a key, a score. It never carries a socket, a token, a URL, or a DOM handle — the host owns the resource, the script holds only a request.

#Asynchronous results come back as messages

A command with a result carries the constructor that its result should be wrapped in. The host applies that constructor to the outcome and delivers a message:

fluxvariant Msg { Save | Saved(epoch: num, ok: signal) | Cancel }

update(m, msg) = match msg {
  Save        -> { model: m with { ui: m.ui with { epoch: m.ui.epoch + 1 } },
                   cmds:  [ SaveLevel(m.doc, m.ui.epoch + 1, Saved) ] }   // ← carries `Saved`

  Saved(e, ok) -> if e == m.ui.epoch                      // a stale result is ignored
                    then { model: m with { ui: m.ui with { saving: 0 } }, cmds: [] }
                    else { model: m, cmds: [] }
}

Why not a task abstraction. A composable Task/effect type would chain A ⤳ B(A) ⤳ C and hide the intermediate results — they would never reach the journal. Re-folding, time-travel, and server-side replay would all lose the ability to reconstruct the model bit-for-bit. So every asynchronous result stays a message. The verbosity of a long chain is the acknowledged price of exact replay, and an optional desugaring of a fixed do … then … sequence into the same message machine keeps the trace identical either way.

The epoch token above is the general answer to a stale result: the command carries an app-supplied scalar; the host echoes it back verbatim; the arm compares it with the current epoch and drops what no longer matters. It lives in ui, never in doc — it must not travel into a shared journal.

#view — a pure tree of vetted primitives

view returns a UiTree: containers (col, row, grid, stack, tabs, scroll, panel, and the application rails), content (text, label, badge, chip, icon, progress, sparkline, image), controls (button, toggle, slider, select, radioGroup, textInput, metaForm), and windows onto the other planeschartView, paneView, sceneView.

The three windows are the only way a UiTree reaches another plane, and each takes a different kind of target: chartView mounts a chart engine and accepts a CANVAS scene as its overlay:; paneView projects a series; sceneView(target, tree, space) paints a free scene into a named render target, in Data, Screen or World3D coordinates. (sparkline appears in the content row above: it renders a series inline, and is not a window onto a target.) See display.

There is no raw markup, no HTML string, no event handler. A click is a message:

fluxview(m) = panel(slot: right.panel) {
  row { text("levels: {m.doc.count}") ; button("add", AddLevel) }
  chartView(chartId: "main",
            onClick: ClickAt,                       // a constructor reference, not a closure
            overlay: overlayOf(m.doc, m.ui.draft, m.ui.sel))
  when m.ui.saving: progress("saving…")
}

Two details in that snippet are load-bearing.

Callback slots take a constructor reference, never a function. onClick: ClickAt names the constructor the host will apply to the real (bar, price) at the moment of the click. The compiler checks the constructor's payload kinds against the slot's declared argument kinds. No function value ever enters the lattice — there is no arrow sort, and this keeps it that way.

The chart is not re-rendered by the view diff. chartView mounts the real chart engine; the reconciler only ever touches the chrome around it. The scene passed as overlay: is a CANVAS value (scene{…}) — the sanctioned channel from the presentation plane into a pane.

The host diffs the small tree, sanitizes it (text goes in as text; an unknown node is rejected), and paints. A view can therefore never inject markup, and a hostile application cannot draw a fake system dialog: the primitive set is closed.

#subs — the declarative front door

Subscriptions are recomputed from each model, and the catalogue is closed. Everything ambient — time, input, randomness, data, the network, the wallet, other users — enters here, as a message.

Subscription Delivers Backed by
OnTick(everyMs, C) a periodic tick clock
OnFrame(C) one message per animation frame clock
OnSeries(key, C) analysis values, read-only chart:read
OnChartClick(C) / OnHover(C) (bar, price) — hover is throttled to bar boundaries chart:read
OnDrawingChange(C) drawings changed chart:read
OnRand(seed, C) seeded randomness rand:seeded
OnFeed(C) a schema-typed network payload net:fetch / net:stream
OnRoute(path, C) a deep link, parsed by a fixed host grammar ui:navigate
OnConnectivity(C) connectivity edges net:offline
OnTransfer(reqKey, C) transfer progress for a request the request's own net:* grant
OnReveal(C), OnRevealProgress(C) a host-computed outcome, and the progress of a reveal — the outcome is the open anti-cheat vector chart:read
OnKey(C), OnPointer(C), OnWheel(C), OnGamepad(i, C) input edges — never a held sample input:*
OnFocus(C) focus gained or lost — journaled, so a key event is only delivered when the journal attests focus — (mediated by slot ownership)
OnVisible(itemKey, threshold, C) a visibility transition per key — the edge behind lazy loading and read receipts
OnPeerMsg(C) a remote journal entry, in a collaborative session net:rtc
OnLocale(C) the active locale changed i18n:catalogue
OnSession(C) the session lifecycle — login, refresh, logout auth:session / auth:passkey
OnEntitlement(C) a server-verified entitlement or subscription pay:checkout
OnGeo(minInterval, C) a watched position, at a declared minimum interval geo:read
OnWallet(C), OnTx(C) wallet and transaction lifecycle events wallet:* / chain:*
OnPresence(C), OnContactUpdate(C), OnInvite(C) social events social:* / present:*
OnSharedChange(scope, C) a change in a hosted shared collection storage:shared
OnWebhook(path, C) an inbound HTTP call, decoded against the declared schema the server plane
OnJob(spec, C) a scheduled run — the server twin of schedule:wake the server plane
OnQueue(name, C) a work item the server plane
NoSub "nothing this frame" — the nullary constructor that makes conditional subscription type

The last four are the server plane's subscriptions: they are delivered to a headless application, and they are what a subs looks like when there is no viewport at all. They belong in this catalogue rather than in a second one, because a headless application is not a different kind of program — it is the same init/update/view/subs with a different set of inputs reaching it. See server and host services.

Each subscription with a payload carries the constructor the host will apply to it, exactly as commands do — OnTick(100, Tick), OnSeries("rsi", Got), OnChartClick(ClickAt). Without that, an event would not know which arm of update it belongs to.

Subscriptions may be rate-shaped without leaving the model: OnSeries(k, Got).throttle(100) delivers at most ten messages a second, and the delivered edge is still journaled, so replay stays exact.

fluxsubs(m) = [ if m.ui.live then OnSeries("close", Got).throttle(100) else NoSub,
            OnChartClick(ClickAt) ]

#Capabilities — the security model

The script never holds a capability object. It emits a requestemit Cap(args) — and the host, the only holder, interprets it through a handler only the host has. A request for a capability the manifest does not grant is rejected at compile time ([ErrCapDenied]), not at runtime. There is no ambient authority: no global object is in scope at all.

A representative slice of the catalogue (every entry is default-deny, host-attenuated, and graded by trust):

Capability Grants Host attenuation
storage:own Persist / LoadPersist a partitioned namespace per application, with a quota
journal Journal(UndoToMark | RedoToMark | JumpToMark) host-mediated truncate + re-fold + re-install
chart:read OnSeries, OnChartClick, OnHover, and the bounded pixel queries read-only, public series, causal bars
chart:ctl SetChart, RevealForward delegated to the chart engine, rate-limited
net:fetch(domain) Fetch, Sub OnFeed user consent per domain; the host holds the socket; the payload is decoded against the schema the app declared and kind-checked at the boundary
net:stream(domain) a persistent bidirectional connection consent per destination; the host holds the file descriptor; typed frames
clock OnTick, OnFrame, After(ms, msg) bounded timers; the deferred message re-enters the journal
rand:seeded OnRand(seed) a server-derived seed through a pinned integer generator
wallet:* / chain:* a signing intent the wallet signs, the user confirms in the wallet; a mandatory decoded simulation precedes any signature; per-argument scoping
ui:contribute:<kind> interface contributions gated at mount
storage:shared hosted collections with tiered ACLs tiers compile to row-level security — never a free-form rule language

Some things are inexpressible for every tier, trusted or not: eval and code generation, raw DOM, a raw socket, a raw database client, a token or a cookie, and any global store. They are not "forbidden by policy" — they have no name in the language.

#Two trust tiers — security is the grant, not the code

The first-party interface and a stranger's application run the same language in the same sandbox. They differ only in what has been granted to them.

Trust is decided by the host from a server-side provenance record keyed by the binary's content hash. A module cannot declare itself trusted, and no embedded metadata is believed.

Neither tier ever relaxes a language invariant. A trusted tier grants effects; it does not loosen causality, no-repaint, totality or the firewall. Repaint is inexpressible for everyone.

#Transitive manifests, zero escalation

An application built on packages aggregates their requests:

manifest(A) = ( ⋃ emit Cap over the transitive closure of A ) ⊓ the user's grant

Three consequences, all normative. A dependency's net:fetch surfaces in the buyer's manifest before install — no hidden capability. No dependency can exceed what the user granted to the application — authority flows only along import edges, capped by the grant. And no dependency holds a capability object, so it can neither re-delegate nor amplify one.

#Revocation is an event, not a check

A grant can be dropped mid-session — by a user gesture, or by a supervisor. The host writes a CapRevoked bound into the journal at that instant, exactly as it writes a pause bound, so that a re-fold reproduces the revocation deterministically. (Without the bound, a re-fold would rebuild the membrane at the un-revoked manifest and a post-revocation command would succeed — a silent divergence, which the plane does not permit.)

Commands then fail closed: one that carries a completion constructor is answered with [ErrCapRevoked] through that same constructor; a fire-and-forget command is dropped and audited. An effect already in flight is cancelled, and the epoch token absorbs any result that was already stale.

#Totality, determinism, replay

Property Mechanism
Totality update/view/subs are total; match is exhaustive; no free loops; the Model is bounded
Determinism update is pure; everything ambient arrives as a message; randomness is OnRand(seed) through a pinned integer generator shared bit-for-bit by interpreter, WASM and server
Exact replay re-folding the message trace reconstructs the Model bit-for-bit — which proves a journal coherent, not unforgeable (see below)
Bounded cost cost per message is bounded; the view diff touches a small tree; heavy effects are commands, outside update

The message journal
Figure — the journal is the single source of truth: undo truncates to a bound and re-folds; a checkpoint keeps that cheap.

The journal is not a flat list of messages. Two refinements are forced by real editors:

#What replay proves — and what it does not

Because a verdict is a pure function of (init, [msg]), a server can recompute it on the same bytes, and a claimed score that was not earned is a journal that does not fold to the claimed result. That property was not designed; it was inherited from determinism. It is also the point at which this page owes the reader a precise limit rather than a slogan.

Replay proves the COHERENCE of a journal, not its NON-FALSIFIABILITY.

"Diverges ⇒ tampered" is complete for a score derived from the seed and from the elapsed time, because the server owns both: the seed is derived server-side from (runId, level, qIndex) and never accepted from the client, and the elapsed time of a ranked run is host-stamped and substituted at re-fold, so a forged Tick count buys nothing. It is not complete for a third class, and the gap is open by name.

A score fed by a host-pushed outcome re-folds without divergence. OnReveal delivers an outcome the host computed — a kernel result, revealed as a message. That outcome enters the journal as data, and a re-fold replays data verbatim: the seed re-derives the messages that came from OnRand, and this one did not. So a forged outcome re-folds to the claimed result without a whisper of divergence. The check passes; the claim is still a lie. The OnReveal row in the subscription catalogue above is exactly this vector, and it is worth knowing which row it is.

The same class covers a pixel. A bounded pixel reading is derived from the client's viewport — its pan and its zoom — and a server with no viewport cannot re-derive it. Hence the standing rule, which is a language-level discipline and not a server one: a pixel value never feeds a ranked verdict. It is a readout, or it is cosmetic.

An outcome-fed run therefore has exactly two honest destinations, and no third: the host re-derives the outcome server-side (re-running the kernel itself, which the server plane is what makes possible), or the run is local-score-only, excluded from the shared leaderboard. It is never accepted on the strength of the client's journal alone. server carries the closing argument.

#The two named exceptions

update is the only producer of a Model in normal operation. Exactly two host-mediated exceptions exist, and both are deterministic:

  1. Time travel. Journal(UndoToMark) makes the host truncate the journal, re-fold (init, [msg]') and re-install the result. The journal remains the source of truth — it is rewound and re-derived, never fabricated.
  2. Migration and hot reload. On a build-hash change, the host either re-folds the retained journal with the new update (state re-derived from the same inputs), or — when the journal is long — decodes the persisted snapshot and passes it through a total migrate(old) -> Model the application declares.

A host-initiated (re)launch — a notification tap, a scheduled wake, a deep link — is not a third exception: its payload is delivered as the first journaled messages of the new session, ordered before any other subscription delivery.

#Schema evolution

While the Msg variant only grows (new constructors, never retyped or removed), an old journal stays re-foldable under a new update — resuming state is free. A breaking change crosses the monomorphic seam through an explicit total upcast (migrateMsg), or surfaces as SchemaMismatch. Never a silent decode of old bytes into a new shape. Snapshot and journal migrate as one unit: a v2 snapshot under a v1 journal would be a split brain. Checkpoints from a previous build are invalidated, not reinterpreted.

#Testing an application

Because update and view are pure, total and deterministic, and the whole fold is inside the byte-identity oracle, an application test is a golden over pure functions — at four grains, with no mocks anywhere.

The trace grain carries the weight: the replay harness is the test harness. A trace test folds a literal message list — fold(init(p), [ Tick, Tick, Reset ]) — and goldens the Model it lands on. Nothing drives a live subscription, and nothing needs to: the journal already reifies every event as a message, so the message list is the mock — total, typed, and the very thing a server re-executes. A trace golden of a ranked score pins the host-authoritative elapsed time and seed as inputs.

The other three grains are ordinary assertions over the same pure functions (m0, m1 and expectedTree stand for the application's own model values and view, as in the note at the top):

flux// step — a deep, na-aware equality on two lattice values
assert update(m0, Tick) == { model: m0 with { n: 1 }, cmds: [] }

// view — a snapshot of the canonical view buffer
assert view(m1) == expectedTree      // chartView nodes are goldened as nodes, never as pixels

// property — an invariant asserted at every intermediate model, over a seeded message list
assert m.count == popcount(m.live)

The cmds field is inert data, so asserting which effects were emitted needs no fake clock and no fake socket: the descriptor is the assertion. And a non-deterministic subscription is never mocked — it is goldened on its description (subs(m)), while its delivery is supplied as a literal list of messages. Where an invariant is already proven by the kind, no test is owed at all.

#Extending the interface

An application declares what it adds — panes, panels, controls, menus, tools, statusItems, commands — and never fabricates it. Each contribution names a slot (a region), a when: predicate, and a render producing a UiTree. Contributing requires ui:contribute:<kind>.

The layout manager owns where; the application owns what. It arbitrates order, docking, tabs and splits, it persists the arrangement, it reconciles and sanitizes the tree, and it contains a failing contribution to its own pane. Each slot exposes a portrect(), onResize, onVisible, requestFocus(), dispose() — whose notifications arrive as messages, and whose lifecycle owns the pane's WASM instance: opening instantiates the module, closing releases its linear memory, drops its subscriptions and revokes its contributions.

An application never owns its display state either. It emits a request (RequestForeground, RequestDetach, …) and the layout manager decides; the verdict returns later as a message. The authority order is total and unforgeable: user > supervisor > application. Forcing your own window to the front is not rate-limited — it is inexpressible.

#The three official patterns

Pattern Shape Where the bulk lives
Slotmap vec(Item, N) + tombstones + live + count + domain ids in the Model, bounded
Document a doc sub-record with declared caps; a Tree(Node, N) node pool; history with bounds and checkpoints. Beyond one editable window, the document persists in chunks and the Model holds the active window host storage; the Model holds the editing window
Feed a bounded window: vec(Item, W) + cursor + total; nearing the edge emits LoadPage(cursor, C); rendering is a virtualList driven by OnVisible the host cache or shared storage

A feed is therefore never an unbounded collection in the Model: it is a deterministic window onto a host-held stock. A large document is the same idea applied to editable content.

#Composition and the firewall

   APP  (mutable state + effects)          ← the most permissive plane
    │  reads ANALYSIS  (Sub OnSeries)              ✔ read-only
    │  orchestrates CANVAS / TRANSITION  (Cmd)     ✔
    ▼
 CANVAS / TRANSITION  (cosmetic, per frame)       ← reads ANALYSIS ✔
    ▼
 ANALYSIS  (pure, causal, no-repaint)             ← reads nothing above it ✘

Four hard rules, checked statically:

  1. Analysis never sees the APP plane. A signal stays provably repaint-free even next to a game — a game that switches asset does not repaint an indicator; it recomputes it, through a capability.
  2. The APP plane reads analysis read-only, through Sub OnSeries.
  3. CANVAS reaches the APP plane through events; the APP plane reaches CANVAS through commands — it orchestrates presentation, it does not rewrite it.
  4. The APP plane never writes analysis.

Reading a live series from update obeys the same floor-containing rule as any resample: the most recent closed bar, never a nearest match. Scoring a quiz cannot repaint the indicator beside it.

#See also

↑ contents

The FDK — the Flux Development Kit

The FDK is everything you program against: the standard prelude, the pinned routines, the pillar APIs, and the capability catalogue. It is the difference between "a language" and "a platform you can build a product on".

Two properties run through all of it, and they are the reason the FDK looks the way it does:

#The prelude

In v1 the prelude is flat — every function is in scope, and the method-style chain does the rest:

fluxplot close.ema(20).rsi(14)     // ≡ plot rsi(ema(close, 20), 14)

After you type close., the editor offers only the functions whose first parameter accepts a price. The type system is the discovery mechanism, which is what makes a flat namespace of a few hundred functions navigable rather than overwhelming.

Modules and qualified names (mod.f) exist, and they extend to packages — see packages.

#The namespaces

Namespace What it covers
math.* Dimensional arithmetic: abs sign min max clamp floor ceil round preserve the dimension; sqrt halves the exponents; pow(x, n:lit) scales them; log exp sin cos tan atan atan2 demand dimensionless input. Every transcendental routes through the pinned library — never the platform's.
stat.* mean stdev variance skew kurtosis median percentile rank correl covar zscore linreg + the exponentially-weighted family. Bounded window reducers; order statistics through the pinned na-ordering.
vec.* map fold scan zip sum avg product min max reverse take drop window · fill · range · setAt · where / mask (length-preserving) · sortBy / topK · count any all. No filter, no flatMap — a data-dependent length would break totality.
decimal.* Exact fixed-point: div round (division names its target scale, half-even; round quantizes to a scale), with bare toDecimal / toFloat as the f64 bridge. long ≡ decimal(18,0), long128 ≡ decimal(38,0).
time.* The calendar: years months weeks days (the only producers of a period), the accessors, epoch conversions, in_session, barsPerYear. now() is a presentation symbol — reading it from analysis is [ErrFirewall].
str.* / fmt.* Bounded text: len slice startsWith endsWith contains indexOf split trim pad rep upper lower; string interpolation; fmt.num/price/pct/time through one canonical formatter. No regular expressions — see text for what replaces them.
ta.* The indicator catalogue — over eighty kernels, each with a kind signature.
anim.* / sig.* The CANVAS signal generators and combinators (tween, spring, wave, noise, stagger, hold…). Presentation-only, by the firewall.
enc.* / crypto.* base64 base32 hex codecs; pinned hashes (sha256, blake3, …), a keyed MAC, and signature verification — a sandbox verifies, it never signs.
id.* Deterministic identifiers: uuidV4(seed), uuidV7(t, seed), nanoid, slug. Seeded, never ambient entropy.
bits.* Bitwise work as named functions (band bor bxor bnot shl shr sar · popcount clz ctz rotl rotr) on the machine word, plus a bounded byte buffer — the substrate for binary codecs. Named, never infix: and/or/not stay the sealed signal logic, and no new token enters the grammar.
geom.* 2-D geometry for drawing tools and custom layout — screen-space by design.
coll.* Ordering and collation combinators; see collections and i18n.
viz.* Data → marks: scales, axes, legends, facets, statistical transforms. See display.

#The pillars

Each pillar is a full API with its own page:

Pillar One line
compute The columnar dataframe algebra, the numeric layer, and the domain libraries.
collections Vec / Deque / Map / Set / Tree — bounded, ordered, value-semantic.
color The color kind, its constructors, perceptual interpolation, and the output channels.
text Structured text, the editing protocol, segmentation, diff, search, validators.
i18n Locales as values, message catalogues, plural and gender selection, collation, RTL.
units meas[u] — general quantities, affine scales, exact conversions.
net The network as a stream: five verbs, typed payloads, declared backpressure.
display Scenes as values, the two strata, panes and windows, viz.*.
host services Files, clipboard, notifications, auth, payments, media, print, fonts, embedding.
server Headless applications, shared storage with tiered access, prerender.
asset & currency The instrument tag (B, Q [, @v]), fx, money, venues.

#The capability model

Nothing in the FDK reaches the outside world on its own. An effect is inert data the script emits; the host — the only holder of the resource — executes it, and only if the capability was declared in the manifest and granted by the user.

fluxapp reader {
  capabilities: [ net:fetch, storage:own, notify:send ]

  init(p)        = { unread: 0 }
  update(m, msg) = match msg {
                     Got(item) -> { model: m with { unread: m.unread + 1 },
                                    cmds:  [ Persist("inbox", item), Notify("new-item", item, Open) ] }
                     Open(hit) -> { model: m with { unread: 0 }, cmds: [] }
                   }
  view(m)        = col { text("unread: {m.unread}") }
  subs(m)        = [ OnFeed(Got) ]
}

Every effect in that body is a request, and each one is answerable to a line of the manifest: OnFeed to net:fetch, Persist to storage:own, Notify to notify:send. Delete a line from capabilities: and the corresponding cmds entry stops compiling.

Three properties make this more than a permission list:

See App plane for the catalogue and the trust tiers.

#Doc-as-data

Every function, kind, keyword and operator carries a structured documentation record — its signature, its kinds, its parameters, its summary, its examples. One source, many renderings:

They cannot drift apart, because they are the same data. And a completeness lint enforces the rule that makes it stick: every construct in the language has a documentation record and at least one runnable example — and every example is a golden. A documented function whose example stops working turns a test red.

#Implementation status

The language core and its analysis-plane surface are implemented; the pillars are sealed designs being built in a frozen order, collections first. Individual pages carry Post-v1. where a feature's rollout follows v1, Reserved. where a seam is deliberately held open and inert, and Open decision. where the design itself leaves a choice open. The overview table is in the README.

#See also

↑ contents

compute — numbers, dataframes and domain libraries

Post-v1. The dataframe layer, the numeric layer and the domain libraries are sealed in design; the scalar namespaces (math, stat, vec, decimal, time, ta) are the v1 surface the analysis plane already uses.

The compute pillar is what makes Flux a general analysis language rather than a chart scripting language. It is a columnar, bounded, kind-typed dataframe algebra — pure, total, fused, dimensional — with a numeric layer (matrices, linear algebra, statistics) and domain libraries above it. A market series is not the foundation here; it is a special case of a table.

Everything in it is built out of the frozen core: a table is a record of columns, a column is a bounded vector, a query is a pure graph. No new sort, no new grammar, no new substrate.

#Six properties, each a theorem rather than a wish

#The scalar namespaces

These are the everyday surface — available in every plane, and dimensional throughout.

Namespace Contents
math.* abs sign min max clamp floor ceil round (dimension-preserving) · sqrt (halves the exponents, so stdev = sqrt(variance) types) · pow(x, n:lit) · log exp sin cos tan atan atan2 (demand dimensionlesslog(price) is [ErrDim], with a quick-fix) · lerp norm
stat.* mean stdev variance skew kurtosis median percentile rank correl covar zscore linreg · the exponentially-weighted family ewmVar ewmStd ewmCov ewmCorr — all bounded window reducers
vec.* map fold scan zip sum avg product min max reverse take drop window · fill(N, x) · range(N) · setAt(v, i, x) · where / mask (length-preserving) · sortBy / topK · count any all
decimal.* div round — division names its target scale (half-even), round quantizes to a scale; with bare toDecimal / toFloat as the f64 bridge — exact fixed-point money
time.* the calendar: years months weeks days (the only producers of a period), the accessors, epoch* conversions, in_session, barsPerYear
ta.* the indicator catalogue
enc.* crypto.* id.* bits.* encoding, pinned hashes and signature verification, deterministic ids, bit and byte-buffer work

Three rules run through all of them and are worth internalizing:

Maths is unit-correct. sqrt halves exponents, pow scales them, the transcendentals demand dimensionless input. This is not pedantry — it is what makes sqrt(variance) → level type-check while log(price) does not.

Order statistics are pinned. median, percentile and rank sort internally, and they use the one pinned total order over na (absent values last, stable by index) — never the platform's sort. percentile names one interpolation method; median on an even count is the midpoint; rank names one tie policy. A window containing a hole yields na, and there is a golden for exactly that.

Higher moments and EW statistics are stable by construction. Skew and kurtosis extend the pinned Welford recurrence; the exponentially-weighted family is a single bounded scan with a pinned λ-recurrence — never the numerically unstable one-pass form.

#Tables

A table is a record of columns, plus a presence mask and a live count:

Table<{ c₁:κ₁, …, cₘ:κₘ }>[N]  ≡  record{
    c₁: vec(κ₁, N), …, cₘ: vec(κₘ, N),   // the columns — struct-of-arrays, one shared row cap
    live:  vec(signal, N),                // 1 = a real row, 0 = a tombstone
    count: num                            // live rows ≤ N
}

A table is a record of columns
Figure — the slotmap of the APP plane, transposed from array-of-structs to struct-of-arrays.

Because it is a record of vectors, it inherits the lattice laws, deep na-aware equality, and projection (t.close) for free — and it compiles straight onto the existing columnar engine. Col(κ, N) is a named alias of a bounded vector; Mat(κ, R, C) adds a second const axis with a rectangularity guarantee.

A Series — the time-indexed data of the analysis plane — is the special case, and the bridge is an identity rather than a conversion.

#Totality, verb by verb

The hardest problem this pillar solves is stated plainly: mainstream dataframes get their power from operations whose output cardinality depends on the data — a filter shrinks, a group-by returns one row per distinct key, a join can explode. Flux forbids that. The five rules that replace it:

Rule
T1 The output cap is a static function of the input caps. N for select/filter/sort/window · min(N,k) for a slice · a declared G for a group-by · Ng for an as-of join · Ng × F for an equi-join with a declared fan-out · Na + Nb for a concat. Never data-dependent — and a derived cap that would exceed N_max is [ErrTotal] at compile time.
T2 Selection is a MASK, never a shrink. A "filter" writes na where the predicate is false and clears the live bit. The length does not move; count does.
T3 Iteration is na-aware, so nothing needs compacting. Aggregations reduce the living rows: tombstones are excluded before the reduction, not absorbed after it.
T4 Overflow is a named policy, never growth. Past the declared cap: Reject (drop the surplus item, with a diagnostic), Truncate (keep the canonical survivors), or Bucket (an "other" bucket). A missing cap is a compile error.
T5 Everything is functional, and lowers to the frozen vec.* primitives — which is why the fusion into one columnar pass comes for free.

The verbs themselves are ordinary, and they chain:

fluxdef liquidBars(n) =
  let bars = series("BTC-USD").toTable(n).derive(range, high - low) in   // ADD a column — a fresh record
  let hot  = bars.where(bars.volume > sma(bars.volume, 20) and bars.range > atr(14)) in
  hot.select(time, close, volume, range)                                 // projection

def dailyVwap(n) =
  let bars = series("BTC-USD").toTable(n) in
  bars.groupBy((r) -> { y: year(r.time), d: dayOfYear(r.time) }, maxGroups: 366)
      .agg((g) -> { vwap: (g.close * g.volume).sum() / g.volume.sum(),   // pv ÷ volume → price
                    hi:   g.high.highest() })

Three details in that snippet carry the whole design.

where takes a column mask, not a row lambda — it never removes a row, it blanks one. The mask is an ordinary column expression over the receiver, which is why the receiver has a name: there is no implicit row variable in a Flux query, so an intermediate table is bound with let … in and its columns are projected off it (bars.volume). A chain that never names its intermediate cannot speak about its columns.

groupBy declares its cap (maxGroups: 366): the memory a group-by needs is therefore known before it runs, which is exactly what an unbounded hash-aggregate cannot promise.

derive adds a column (it builds a fresh record); with { … } redefines an existing one. The distinction is not stylistic — with is shape-preserving by law, so adding a field through it would be [ErrField].

#Joins

As-of is the primitive, not an afterthought — because time-series data is what most joins in this domain are about, and because it is the join whose output cap is trivially static (one row per left row):

fluxdef joins(quotes, refs) =
  let bars = series("BTC-USD").toTable(256) in
  { aligned: bars.asofJoin(quotes, key: time, take: [bid, ask]),      // the most recent quote at or before each row
    paired:  bars.join(refs, key: symbol, kind: Inner, maxFanout: 4) } // equi-join with a DECLARED maximum fan-out

An equi-join without a declared maxFanout does not compile. That is the price of never having a query explode in production, and it is a price worth paying.

#The execution model

A query is a DAG of pure nodes, and it is the same DAG the engine already optimizes. So:

Execution follows the morsel → sink model: the columns are cut into chunks, chunks flow through the fused pipeline, and the results land in the sink. Parallelism applies to independent reductions (distinct groups, distinct cells) — never to the interior of a single reduction, because that would reassociate floating-point and break byte-identity.

Why the reduction order is pinned even when it costs speed. A parallel sum is faster and gives a different last bit. Two engines would then disagree, replay would drift, and a server could not verify a client's work. The reduction order is therefore fixed, and the parallelism is found where it does not change a single bit.

#The numeric layer

Mat(κ, R, C) with two const axes, and above it:

#Domain libraries

Function What it computes
sharpe(returns, rf, barsPerYear(clock)) the annualized ratio, with the base made explicit
rollOls(y, x, 60) a rolling regression — bounded window
drawdown(equity) the running peak-to-trough
impliedVol(price, strike, t, r) an option surface point
ytm(cashflows), duration(bond) fixed income — a yield, and a time-sensitivity
laspeyres(p0, p1, q0), paasche(p0, p1, q1) index numbers

Every one of them is a bounded composition of the primitives above — which means each of them is also readable, and each of them type-checks dimensionally. sharpe cannot silently annualize with the wrong base, because the base is an argument whose kind says what it is.

geom.* is the 2-D geometry family — distances, point-in-shape, intersections — used by drawing tools and custom layout. It is screen-space by design: data-space projection belongs to the host, which is what keeps the firewall intact.

#The spreadsheet

Post-v1. A bounded, reactive dataframe with a cell grammar (SUM, AVERAGE, VLOOKUP, SUMIF, COUNTIF, …), evaluated by the same graph engine with early cut-off — a sheet is a query that recomputes only what changed. It is included here because it falls out of the algebra rather than being bolted onto it: a sheet is a bounded table plus a dependency graph, and Flux already has both.

#Reserved seams

Reserved. metric[id] — a non-price series (an economic indicator, an on-chain metric, an analytics stream) entering the analysis plane as a first-class causal stream, tagged with its identity so that adding a CPI series to a hashrate series is [ErrDim]. The seam is designed and inert in v1; nothing non-price enters analysis until it is armed.

#See also

↑ contents

collections — bounded, ordered, value-semantic

Post-v1. 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.

#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 placeEfficiency): 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:

fluxrecord 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.

fluxavgVol = 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 toVecThe 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.

fluxdef 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.

Post-v1. A priority queue (Heap) is deferred: topK already covers the top-k need, and the heap arrives with the graph-algorithms lot that actually requires it (§ The implementation order).

#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)
fluxsyms   = 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:

fluxdef 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.

fluxlevels = 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:

fluxrecord 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:

fluxrecord 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.

fluxleaf = 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).
fluxavgVol = sma(volume, 20)
hot    = vec.where(window(volume, 64), (v) -> v > avgVol)   // ✓ same length, na where the predicate is false
fluxhot = 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:

fluxn    = 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:

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

Post-v1. 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.

fluxdef 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:

fluxtype 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:

fluxrecord 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:

fluxrecord 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). 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. 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:

#The implementation order

The order is frozen, and it is dependency-clean — each lot needs only the ones before it, and each is pulled by a named consumer rather than by symmetry:

Lot Unblocks
1 Vec — done; retrofitted as the framework's sequence face everything
2 Map / Set — ordered, hash-free, in-place the inverted index and the prefix range-scan (typeahead, with no trie kind), the keyed slotmap, counters and bags
3 Tree — node pool and catamorphism the Markdown AST of the text pillar and the document pattern; Tree ships with or before the Md codec
4 Deque — at its first real consumer a breadth-first frontier, a work-list, an animation queue
5 Heap / priority queue — with the graph-algorithms lot Dijkstra and A*, which are the only callers that actually need it; BFS, DFS, topological order, components and cycles need only lots 2 and 4
6 Pinned-hash Map, finger-tree Deque — evidence-based, behind the same API nothing, until a profile says otherwise

The net of it: 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

↑ contents

units — general quantities

Post-v1. The units pillar is a sealed, governed amendment to the kind system.

A metre is not a second. A kilogram is not a kilobyte. Twenty degrees Celsius plus twenty degrees Celsius is not forty degrees Celsius — and a language that lets you write it is a language that will eventually produce a number you cannot defend.

The units pillar carries physical and general-purpose quantities in the type system, with the same machinery the market kinds already use: a tag on num, exact conversions, and rules that make the meaningless cases fail to compile.

A note on the samples. Flux has no expression-statements, so a bare expression is not a program. The lines marked on this page are therefore expression fragments: they exist to show what the kind rules refuse, not what the parser accepts. Every unmarked line is a legal statement.

#Three regimes, one rule each

Real units fall into exactly three classes, and each gets its own treatment. This is the settled consensus across the systems that have tried, and it is worth stating up front because most half-measures come from conflating them:

Regime Examples Treatment
Linear length, mass, volume, data size, speeds and rates the tag, plus exact multiplicative conversion
Affine temperature (°C, °F, K) the same tag, plus a point | delta bit that selects the right formula and outlaws the meaningless arithmetic
Nonlinear decibels, pH, magnitudes not units. Explicit pure functions — a unit conversion never leaves its convexity class

Nothing is removed by that third row: decibels still work. They work as a function, because that is what they are.

#The tag

meas[m]         meas[m·s⁻¹]        meas[kg·m·s⁻²]        meas[B]        meas[px]

A unit is a product of symbols with integer exponents, drawn from a closed, versioned catalogue. Each symbol declares its family (length, mass, data, temperature…), its exact factor to that family's canonical unit — a pinned rational, so km = 1000 m and KiB = 1024 B and mi = 1609344/1000 m are exact, not approximate — and, for temperature only, its affine slope and offset.

Structurally this is the same move the currency-pair annotation already made: an annotation axis carried by num, whose top is the bare num. Zero new sorts, zero new lattice height.

fluxd       = unit.km(5)        // meas[km]
elapsed = 7200s             // duration — two hours. NOT a unit: see below
v       = d / elapsed       // meas[km·s⁻¹] — a distance ÷ a duration: the time bridge

Two constraints on the catalogue are worth knowing, because they are the ones that surprise people:

#The algebra

± demands the identical unit. Different units, no conversion, no sum:

fluxside = unit.m(5) + unit.m(3)   // meas[m]  ✓
unit.m(5) + unit.ft(3)         // ✗ [ErrDim] — convert first: unit.m(5) + toUnit(unit.ft(3), m)

× and ÷ compose exponents and unify symbols within a family, folding the exact factor:

fluxr = unit.km(1) / unit.m(1)     // ratio — the same family cancels, ×1000 folded exactly
v = unit.m(6) / 2s             // meas[m·s⁻¹] — the TIME BRIDGE: a measure ÷ a duration

Full cancellation gives you a plain ratio, as it should.

The time bridge is how s enters a tag. There is no unit.s(…) to divide by — the second reaches the algebra only as the kind that owns it, duration. The bridge runs both ways: meas[u] ÷ duration → meas[u·s⁻¹] (distance ÷ time = speed) and meas[u·s⁻¹] × duration → meas[u] (speed × time = distance). Time components canonicalize to s, so an h or a min in a tag normalizes before it nets, and an expression whose tag nets to pure time returns a duration again — meas[m] ÷ meas[m·s⁻¹] is an ETA. That closed loop is what makes a stranded meas[s] unreachable rather than merely discouraged.

The mixed wall. A market dimension and a physical unit do not multiply:

fluxclose * unit.kg(2)          // ✗ [ErrDim] — a price is not a mass, and their product means nothing

Finance keeps its own axes — currency through the asset tag, calendar time through period, angles through their own unit — and the wall between them is an enumerated edge, not an accident of the rules.

#Affine scales: the point / delta bit

This is the part every units library gets wrong at least once. A temperature can be a point on a scale (it is 20 °C outside) or a difference on that scale (the temperature rose by 5 °C). They convert differently, and only one of them can be added:

fluxt  = unit.tempC(20)          // meas[°C · point] — a POINT: 20 degrees Celsius
dt = unit.tempCDelta(5)      // meas[°C · delta] — a DIFFERENCE of 5 degrees

warm = t + dt                // meas[°C·point]  ✓  point + delta = point   (25 °C)
rise = t - t                 // meas[°C·delta]  ✓  point − point = delta
t + t                        // ✗ [ErrDim] — "20 °C plus 20 °C" is not 40 °C. It is nothing.
hot  = toUnit(t,  F)         // 68 °F   — the affine formula: slope AND offset
dHot = toUnit(dt, F)         // 9 °F    — the linear formula: slope ONLY

If that distinction looks familiar, it should: it is exactly the point/vector distinction the price axis already makes (price − price = level). The units pillar does not invent a mechanism — it reuses the one the language was built on.

The bit also disciplines the functions that read a scale's arbitrary zero:

Operation On a point On a delta or a linear unit
abs, sign [ErrDim] — they read the zero, which is arbitrary
sum over a collection [ErrDim]
a rate of change [ErrDim]
floor, ceil, round ✓ (quantization within the scale)

Why abs(20 °C) is refused. The absolute value of a temperature is not a temperature — it is a statement about the distance from a zero that somebody chose in 1742. On the Kelvin scale the same expression would give a different answer, and both would be "correct". The compiler refuses to pick one, and offers you the conversion that makes your intent explicit.

#Getting values in and out

In: a declared meta-head on an input, checked against the catalogue.

Out: meas.value(x) strips the tag when you genuinely want the bare number, toUnit(x, u) converts, and meas.valueIn(x, u) does both in one call. There is no implicit coercion, ever — meas[u] ≤ num is a lossy edge, so it warns and offers a quick-fix rather than silently discarding the unit that was the whole point.

An affine point is excluded from that lossy tier altogether. A bare-num site is scale-ambiguous for a point — 20 °C and 68 °F are the same temperature and different numbers — so meas[°C·point] ≤ num is a hard [ErrDim], not a warning, and a bare meas.value on a point is [ErrArg]. The quick-fix is valueIn, whose signature forces you to name the scale at the exit: meas.valueIn(t, F) is 68, and it says so in the call. The lossy half of the rule is the convenience; this half is the one that catches the bug — it is the same arbitrary-zero argument that refuses abs(20 °C), applied at the boundary.

Formatting is locale-aware and goes through the pinned tables, so a measurement renders correctly without the number becoming locale-dependent — see i18n.

#What this costs and what it buys

It costs a tag on num, one bit for affine scales, and a closed catalogue. It buys:

fluxdistance = unit.km(5)                // meas[km]
bytes    = unit.B(2048)              // meas[B]
elapsed  = 7200s                     // duration — two hours
speed  = distance / elapsed          // meas[km·s⁻¹] — a distance ÷ a duration, and the compiler knows it
budget = bytes / elapsed             // meas[B·s⁻¹]  — a bandwidth, correctly
wrong  = distance + elapsed          // ✗ [ErrDim] — caught here, not in production

And it composes with everything else: a column of measurements in a table, a measurement in a Model field, a measurement rendered by a chart — all of them carry the unit, and all of them refuse the same nonsense.

#See also

↑ contents

asset & currency — the instrument tag

A price is a rate: so many units of a quote currency per unit of a base instrument. Once you say that out loud, a whole class of bugs becomes a type error — because "BTC priced in dollars" and "BTC priced in euros" are then visibly different things, and adding them is visibly nonsense.

That is the entire pillar. A structured asset tag rides on the price-dimension kinds, the operators gate on it, and fx and money fall out as tagged versions of kinds that already existed. Zero new sorts.

A note on the samples. Flux has no expression-statements, so a bare expression is not a program. The lines marked on this page are therefore expression fragments: they exist to show what the kind rules refuse, not what the parser accepts. Every unmarked line is a legal statement. The bracket notation (price[BTC,USD], fx[USD/EUR]) is how this page writes a kind in prose and in comments; it is not source syntax.

#The tag

price[B, Q]        level[B, Q]        pv[Q]        volume[B]
Component What it is Carried by
base B the instrument every price-dimension kind — except pv, which drops it (deliberately)
quote Q the currency the price is in — the unit the value is measured in every dimension containing price
venue @v optional third component, opt-in, default off the same kinds, when enabled

Dimensionless kinds — ratio, osc, num, signal, dir — carry no asset tag. A relative strength is a number; it does not belong to an instrument.

Each component has its own top (⊤base, ⊤quote, ⊤venue), and the join widens the component that differs while preserving the one that matches:

price[BTC,USD] ⊔ price[ETH,USD]  =  price[⊤base, USD]     // the quote survives
tag ⊔ ⊤component                 =  ⊤component            // never an error

#Safety comes from the algebra, not the join

This is the design decision worth understanding, because it is counter-intuitive at first: the join is permissive (it widens), and the safety lives in the operators.

fluxbtcUsd = series("BTC-USD").close   // price[BTC,USD]
ethUsd = series("ETH-USD").close   // price[ETH,USD]
btcEur = series("BTC-EUR").close   // price[BTC,EUR]

btcUsd + ethUsd                    // ✗ [ErrDim] — different bases
btcUsd + btcEur                    // ✗ [ErrDim] — different quotes: a dollar is not a euro
move = btcUsd - btcUsd             // level[BTC,USD] ✓

± demands identical tags, component by component. That single rule catches the two mistakes that matter — mixing instruments, and mixing currencies — and it catches them at compile time, where a silent wrong number would otherwise have been produced.

Ordering and equality gate the same way. price[BTC,USD] < price[BTC,EUR] does not compile.

#Division: a 2×2, and one of its cells is an exchange rate

fluxbtcUsd = series("BTC-USD").close
ethUsd = series("ETH-USD").close
btcEur = series("BTC-EUR").close
ethEur = series("ETH-EUR").close

same = btcUsd / btcUsd       // ratio        — same base, same quote
rel  = btcUsd / ethUsd       // ratio        — base differs, quote shared: relative strength; the tag is dropped
rate = btcUsd / btcEur       // fx[USD/EUR]  — SAME base, quote differs: this IS an exchange rate
btcUsd / ethEur              // ✗ [ErrDim]   — both differ: no shared axis to cancel, so the ratio is undefined

The third cell is the interesting one. Divide the same instrument priced in two currencies and the instrument cancels — what remains is the rate between the currencies. Flux names that: fx[USD/EUR].

The fourth cell has nothing to cancel: a different base and a different quote share no axis, so the division is [ErrDim] rather than a silent number. A ratio needs a common denominator — a shared quote for a cross-base strength, a shared base for a cross-quote rate — and when neither is present there is no defensible value to return.

There is no fxRate(a, b) primitive. An fx value is derived — by that division, or by a feed that declares itself an exchange-rate source. That is deliberate: a rate you conjured from a symbol name is a rate nobody checked.

#Multiplication: conversion is unit cancellation

fluxbtcUsd    = series("BTC-USD").close
btcEur    = series("BTC-EUR").close
usdPerEur = btcUsd / btcEur      // fx[USD/EUR] — derived by the division above, never conjured
eurPerUsd = btcEur / btcUsd      // fx[EUR/USD] — its reciprocal

back = btcEur * usdPerEur        // price[BTC,USD] ✓ — the shared quote cancels: (EUR/BTC)·(USD/EUR)
also = btcEur / eurPerUsd        // price[BTC,USD] ✓ — the reciprocal converts the same way

Currency conversion is not a special rule bolted on. It is the ordinary exponent algebra, applied to a tag that happens to name a currency — which is exactly what makes it hard to get wrong.

Cross-asset multiplication widens rather than failing (price[BTC,USD] × price[ETH,USD] is a kind with widened tags): a product of two instruments is unusual but not meaningless, and the rule that catches the real mistakes is ±, not ×.

#The money-flow: pv drops the base

Multiply a price by a volume and you have a money-flow — a notional amount of currency. The base pairs, then cancels, deliberately: a flow of money is base-agnostic, an amount in a currency rather than a quantity of an instrument. So pv[Q] carries the quote alone.

fluxbtc  = series("BTC-USD")
eth  = series("ETH-USD")
btcE = series("BTC-EUR")

flowBtc = btc.close * btc.volume       // pv[USD] — the base pairs, then drops
flowEth = eth.close * eth.volume       // pv[USD]
flowEur = btcE.close * btcE.volume     // pv[EUR]
book    = flowBtc + flowEth            // pv[USD] ✓ — notionals in one currency compose
flowBtc + flowEur                      // ✗ [ErrDim] — a dollar flow is not a euro flow

That two USD money-flows add is the point, not an oversight: a dollar of BTC notional and a dollar of ETH notional are the same dimension, and summing them is exactly the portfolio total a book wants — a dollar is a dollar. The mistake ± still catches on a money-flow is the currency mix, never the base mix. Per-asset discrimination, when you want it, comes from holding the per-asset pv in a vec or a Table keyed on the base — never from re-tagging the flow.

Dividing a flow back is the ordinary group algebra, no special rule: pv[Q] ÷ volume[B] → price[B,Q] recovers the price, and pv[Q] ÷ price[B,Q] → volume[B] recovers the size — the same P·V exponents that built the flow, run in reverse.

#fx and money invent nothing

Notation Actually is
fx[Q1/Q2] the existing ratio kind, wearing a currency-pair annotation
money[Q] decimal pv[Q] — an exact fixed-point money-flow

Zero new sorts, zero new lattice height. The pair annotation is a fourth tag axis that lives on ratio alone — and since ratio carries no asset tag, the ceiling of three tags per kind is preserved.

#Venue and source

The venue is where a price came from. It is metadata by default — carried on the producer, not in the kind — because tagging every price with an exchange would fragment the type of every expression that touches two of them, for a safety nobody asked for.

Open decision. The venue may be enabled as an opt-in third component of the tag, for the arbitrage case where two prices of the same instrument on two exchanges must not be interchangeable. It is designed, and off by default.

Reserved. pinVenue — pinning a series to a venue at the type level — is specified and inert in v1.

A producer declares what it is:

Producer kind Meaning
Index a computed index, not a tradable instrument
Venue an exchange feed
Fx an exchange-rate feed — the second way an fx value can arise
Metric Reserved. a non-price series (the metric[id] seam)

toSource(key) is the seam that stamps a stream's tag onto the series it produces, and hands it to the host for append-only causal ingestion — which is how an external feed becomes an ordinary, repaint-free series (net).

#Cross-series work

fluxbtc    = series("BTC-USD")
eth    = series("ETH-USD")

spread = btc.close / eth.close                                    // ratio — plottable
corr   = stat.correl(returns(btc.close), returns(eth.close), 30)  // osc(-1,1)
rel    = series("ALT-USD").close / btc.close                      // relative strength

The foreign series is aligned onto the chart's ordinal axis by an as-of join — the most recent foreign bar at or before the current bar's time. Never a nearest match, which would read the future. Gaps hold the last known value; before the first foreign bar the value is na. No-repaint is inherited rather than re-argued.

#See also

↑ contents

text — strings, structured text, and editing

Post-v1. The string kind and the str.* / fmt.* surface belong to the sealed core. The structured-text codec, the sanitized render, the editing protocol, highlighting, segmentation, diff, search and the validator catalogue above them are a sealed additive design whose rollout follows v1.

Text is the substrate a general application language cannot avoid. A forum has posts, a course has lessons, an editor has a document, a form has a field somebody typed into, and a chart has a label under a mark. Flux carries all of it — under one wall and one unlock. The wall is rule A12: no regular expressions, no arbitrary parsing. The unlock is the move that already carried the feed and form codecs, the calendar tables and the Unicode tables — a fixed grammar compiled to a declared kind, bounded and pinned. The Unicode tables are the precedent that matters most here, because this pillar leans on them for the next four hundred lines: case, normalization, and the segmentation that tells a caret where it may stand. Nothing on this page relaxes A12; this pillar is what populates it, and the last two sections show why the ban is satisfiable rather than merely restrictive.

Reading the examples. A line marked ✗ is often a bare expression fragment — Flux has no expression-statements, so it illustrates a kind rule rather than a program. Positive samples are always legal statements.

#The string kind

string is bounded, immutable UTF-8 text: labels, prompts, messages, keys, a document's source. It is a categorical sort — flat, like color — and four consequences follow.

It is outside numeric arithmetic, with exactly one overload. + on two strings is concatenation: the single categorical line in the + rule table. Nothing else in the numeric algebra touches a string.

It has no ordering. There is no string < string, because a defensible answer would need a locale, and a locale read out of the air is a value that differs between two readers. Equality is admitted — bit-equality on the UTF-8 bytes, which every engine computes identically. A locale-aware order exists, but only as a named, explicit, pinned combinator: see i18n.

It is never a series. A string is consumed by the channels that expect text — a mark's label, an alert's message, a ui node's content — and it is not plottable.

fluxsym   = "BTC-USD"
label = sym + " — " + fmt.price(close)     // string + string → string
alert close cross_up ema(close, 50) "{sym} crossed its 50"

same = "a" == "b"          // ✓ signal — bit-equality
"a" < "b"                  // ✗ [ErrDim] — `string` is never an ordered kind
plot label                 // ✗ [ErrPlot] — a string feeds text channels; it is not traced

It is bounded. Every string has a length cap — declared at the boundary that produces it (a text input's maxLen, a codec's maxTextLen) or inherited from the global cap. Overflow is a deterministic truncation, and the truncation cuts at a scalar boundary, never in the middle of a scalar (which would leave invalid UTF-8). This is the vec(κ, N) discipline applied to text: the memory an instance can occupy is computable before it runs.

#The unit is the Unicode scalar

len, slice, indexOf and split count and index in Unicode scalars (code points). Never a byte. Never a UTF-16 code unit.

Why this rule exists. The interpreter runs on the platform, and the compiled module runs on its own UTF-8 memory. If the interpreter delegated len to the platform's string length, it would be counting UTF-16 code units, and the compiled module would be counting scalars. The two agree on ASCII and diverge on the first character outside it: a label carrying a currency symbol, an accented name, a CJK title or an emoji would have one length here and another there. Byte-identity (invariant I7) is the property that lets a script be re-executed and trusted; it cannot survive a string API that means two different things. So str.* never delegates to the platform's string methods — it runs the same pinned routine on both sides.

Three units exist in this pillar, they are not interchangeable, and the difference between them is not academic:

Layer Unit Where you meet it
Storage UTF-8 byte invisible to the program; the small-string threshold is a storage detail
The string kind Unicode scalar len, slice, indexOf, split, rep; truncation at the cap
Editing and display grapheme cluster Caret.off, Sel, truncate, Tok.start/len, diff lengths, highlight spans
Text Bytes Scalars Graphemes
abc 3 3 3
é as one code point 2 1 1
é as e plus a combining acute 3 2 1
a zero-width-joiner family emoji 25 7 1

The scalar is the canonical unit of the kind — the one two engines must agree on. The grapheme is the unit of the user — what a person calls "a character", and therefore what a caret must step by: stepping by scalars would split that family emoji into four people and three joiners, and let an insertion point land between a letter and its accent. Both are pinned; neither is the platform's.

#str.* and fmt.*

Function Notes
len, slice, indexOf, contains, startsWith, endsWith scalar-indexed; slice is bounded
split(s, sep, maxParts) the part count is declared, so the result length is const-folded
trim, pad, padStart, padEnd, rep(s, n) rep's count is a literal — the output length is known at compile time
replace(s, from, to) literal replacement, bounded — not a pattern
upper, lower locale-invariant, through the pinned Unicode case table
normalize(s, form) NFC / NFD, riding the same sealed table
truncate(s, n) grapheme-safe — it never cuts a cluster in half
graphemes(s), graphemeAt, graphemeSlice the segmentation surface (below)
fmt.num, fmt.price, fmt.pct, fmt.time the pinned canonical formatter
fmt.cat the concatenation an interpolated literal desugars to

#The pinned formatter

fmt.num/price/pct/time are one canonical routine, shared byte-for-byte by the interpreter, the compiled module and the server. They are never the platform's number-to-string.

Why this rule exists. Two engines disagree about numbers-as-text in ways nobody notices until a golden fails: how many decimals a f64 renders by default, which way the last digit rounds, and at what magnitude the output flips into scientific notation. A single divergent digit in a label is a divergent output, and the byte-identity oracle would then fail on every script that prints a number — which is nearly all of them. Text formatting is held to the same standard as the transcendental functions: one pinned routine, one golden, no exceptions.

upper and lower are locale-invariant for the same reason. That is a deliberate limit, not an oversight: locale-aware case belongs to i18n, where the locale is an explicit argument and the tables are versioned.

#Interpolation

A { inside a string literal opens a hole holding a full Flux expression. The literal lexes into fragment tokens and the parser interleaves the expressions; the AST is a fmt.cat of the fragments, with each hole desugared through fmt.* according to its kind. So a label is dynamic without any string-building API:

fluxsym   = "BTC-USD"
stamp = fmt.time(time, "HH:mm")
mark close cross_up ema(close, 50) "{sym} crossed at {fmt.price(close)}"

Literal braces escape as \{ and \}, and both delimiters — "…" and '…' — behave identically; the token-level details are in lexical structure.

#Memory: small strings, the arena, and promotion

Nearly every string in an application is short — a label, a formatted price, a key, a prompt. Those live inline in the value itself (the small-string optimization) and cost zero allocations. Longer ones go into a bump arena that is reset once per evaluation tick — per bar, per frame. There is no garbage collector: Flux is pure and its lifetimes are bounded, so the arena's reset is the deallocation.

Concatenation fuses. The line below does not build three intermediates: it compiles to one length computation and one arena write — the string-builder pattern, made invisible by purity and common-subexpression elimination.

fluxdef tag(c) = "px " + fmt.price(c) + " @ " + fmt.time(time, "HH:mm")

record Model { last: string ; n: num }      // `last` outlives the tick → promoted out of the arena

Promotion. A string that survives its tick is materialized out of the arena: copied into node-lifetime or Model-lifetime memory, never left as a view. Three things trigger it — capture by a scan or a stateful node, a field of a Model, and a checkpoint.

Why promotion is not an optimization detail. A checkpoint that stored a slice-view into a per-tick arena would, after that arena had been rewritten a thousand times, restore whatever happened to be sitting at those offsets. Scrubbing backwards through a session would produce different text on every attempt, and the replay would not be bit-exact. Copying on promotion is what reconciles "garbage-less" with "replayable" — two properties this language refuses to trade against each other.

#Structured text — the Md codec

Markdown-class documents enter through a codec, not a parser. What the grammar admits (closed and versioned as md-v1, a strict CommonMark subset): ATX headings 1–6 · paragraphs · emphasis and strong · inline code · fenced and indented code blocks · blockquotes with bounded nesting · ordered and unordered lists with bounded nesting · thematic breaks · links · images · tables with bounded columns · hard breaks.

Permanently excluded: raw markup passthrough. There is no production for it, and the sanitizer below would not accept it if there were.

Post-v1. Footnotes and definition lists are not permanent exclusions — they are named for a later, additive grammar version (md-v2), arriving as a new pinned version with its own golden, exactly as a table bump does.

The output is a bounded node-pool tree, not a recursive kind:

fluxvariant MdNode {
  Doc | Heading(level: num) | Para | Em | Strong | Code | CodeBlock(lang: string)
  | Quote | List(ordered: signal) | Item | Link(href: string) | Image(ref: string)
  | Table | Row | Cell | Text(s: string) | Break | Rule
}

The document is a Tree(MdNode, N) — the node pool from collections. The caps are declared at the decode site, and overflow is a bounded truncation with a diagnostic, the same discipline as vec(κ, N):

fluxMD_CAPS = { maxNodes: 2000, maxDepth: 8, maxTextLen: 4000 }

def article(body) = md.parse(body, MD_CAPS)     // → Tree(MdNode, N)

Two entry points, one pinned routine. Md in the net codec catalogue decodes a fetched body straight to the tree at the boundary; md.parse(s, caps) does the same in the script, which an editor needs in order to preview a draft living in the Model. They are the same routine — one source of truth, interpreter ≡ compiled module, with a golden per grammar version.

Why this is a codec and not a parser. A parser is a program that runs on data, and its cost is a function of the data. A codec is a projection into a declared kind: the grammar is fixed before the program runs, the depth and the node count are capped at the call site, and the work is therefore bounded by numbers the compiler can read. The distinction is exactly what makes totality survive contact with text. It is also why decoding a payload never appears in your code as parsing — see the last section.

A link is data. Link(href) carries a string, and a string is not an authority. An href becomes navigation, and a ref becomes a load, only at the render boundary, under the host's policy. A tree that arrived from the network cannot reach anything by itself.

#The sanitized render

The text pipeline
Figure — bytes become a bounded tree, the host holds every authority, and only committed edits enter the journal.

richText(ast) -> ui is a host-rendered primitive in the closed ui catalogue: the host walks the tree and renders vetted constructs only.

Node What the host does
text runs inserted as text content, never as markup; typography from tokens
Link(href) a host-vetted anchor: internal routes resolve through the navigation allowlist; an external href gets the host's external-link affordance and opens through the host
Image(ref) resolved only through asset:load (allowlist plus quota), or dropped with a placeholder and a diagnostic
CodeBlock(lang) highlighted (below)
a malformed node rejected

Why images go through the asset policy. A fetched document that could hotlink a pixel would be a tracking beacon, and the reader would have no way to know. Routing every image through the allowlist means a document loads only what the application's asset policy already admits — the network cannot introduce a new origin by writing one into a link.

There is no "unknown node class" in transit: MdNode is a closed variant produced by a pinned routine, so the sanitizer never guesses at a foreign tag — it judges only the malformed, which is a far smaller and far more decidable job. A prose container then wraps long-form output with a reader-width measure and vertical rhythm tokens; course pages, documentation bodies and forum posts are its consumers.

Open decision. Relative hrefs inside a fetched document: resolve them against the feed's origin, or forbid them outright. The plan leaves this open.

#The editing protocol

A rich-text editor is a widget in the ui catalogue, but the interesting part is not the widget — it is the protocol underneath it, designed once, here, so that editing is replay-exact no matter what the host's input stack does. Positions are data, and they are grapheme-safe:

fluxrecord Caret { node: num ; off: num }       // `off` counts GRAPHEME CLUSTERS
record Sel   { anchor: Caret ; focus: Caret }

Edits are messages. The host widget delivers them through constructors the application declared — the same OnX(args, C) carve-out every host event uses:

fluxrecord Caret { node: num ; off: num }
record Sel   { anchor: Caret ; focus: Caret }
variant EditOp  { Insert(at: Caret, s: string) | Delete(r: Sel) | Replace(r: Sel, s: string)
                | SetSel(r: Sel) | SetMark(r: Sel, m: MarkKind) }
variant MarkKind { Em | Strong | Code | Link(href: string) }

One reducer applies them. text.apply(doc, op) -> doc is a single pinned, total function — and totality holds at the cap, not below it: no input, and no sequence of inputs, makes it fail to return a document.

Situation Behaviour
a position outside the valid range clamped to the range
an operation wholly outside the document no-op plus a diagnostic (the vec.setAt precedent)
an edit spanning node boundaries the node pool splits and merges deterministically
Replace(r, s) exactly Delete(r) then Insert(…, s) — so it inherits both disciplines
SetMark over part of a text run the run splits at the selection edges
an edit that would overflow the pool's cap N no-op plus a diagnostic — never an overrun

IME composition never enters the journal. While a composition is in flight, its intermediate states are presentation-local — continuous-class input, the same class as a drag's in-flight position or a scroll offset. Only the committed text lands, as an Insert or a Replace message.

Why the journal only sees commitments. Input-method engines differ — the same keystrokes produce different intermediate candidate strings on different platforms and different versions. If those intermediates were journaled, a session recorded on one machine would not re-fold on another, and undo would step through candidate states no user ever chose. Journaling the commitment makes replay byte-exact across IME engines, and makes undo mean what a writer expects it to mean.

Undo is the application journal — its bounds and its coalescing, nothing else. There is no second undo stack inside the editor, which is why undo cannot resurrect a stale selection: the Model's doc sub-record is versioned by history, and its ui sub-record is not.

fluxvariant Msg { Edit(op: EditOp) | Move(r: Sel) | Undo }

app notes {
  capabilities: [ storage:own, journal ]

  init(p)        = { doc: md.parse(p.seed, MD_CAPS), ui: { sel: p.sel } }   // MD_CAPS: above
  update(m, msg) = match msg {
                     Edit(op) -> { model: m with { doc: text.apply(m.doc, op) }, cmds: [] }
                     Move(r)  -> { model: m with { ui:  m.ui with { sel: r } },  cmds: [] }
                     Undo     -> { model: m, cmds: [ Journal(UndoToMark) ] }
                   }
  view(m)        = prose { richText(m.doc) }
  subs(m)        = []
}

Move writes only into ui, so a caret movement is not an undoable step; Edit writes into doc, so it is. The partition is the undo semantics. A plain multi-line textarea is the same protocol minus SetMark.

#Syntax highlighting

fluxrecord Tok { start: num ; len: num ; class: TokClass }     // start/len in GRAPHEME clusters
variant TokClass { Kw | Ident | Num | Str | Comment | Op | Punct | Plain }

TXT_CAPS = { maxTextLen: 4000 }
def toksOf(src) = hl.tokens("flux", src, TXT_CAPS)         // vec(Tok, N)

The grammars are a closed catalogue, exactly like the codecs: bounded single-pass tokenizers, pinned and versioned per language. The initial set is flux, json, js, html-escaped, md. codeBlock(lang, text) -> ui renders the classes through the theme's tokens.

An unknown lang renders as plain text with a diagnostic — never a guess. Detection by heuristic is a non-goal: it would make a document's rendering depend on a classifier, and a classifier is exactly the kind of thing that changes its mind between two versions.

Open decision. Which languages the catalogue takes on after the committed set; the plan lists candidates without choosing.

#Unicode segmentation

The tables are pinned and versioned, and they are the foundation the editing model stands on:

Table Gives you
Grapheme clusters (UAX #29) str.graphemes, graphemeAt, graphemeSlice; caret arithmetic; truncate
Word and line break (UAX #29 / #14) word-jump for the caret; wrap hints for the renderer
Case and normalization upper, lower, normalize (NFC / NFD) — one sealed table, two uses

The routine is shared between the interpreter and the compiled module, and it is never the platform's segmenter. This is the formatter's argument again: a platform table is a moving target that ships on the platform's schedule, and two engines on two versions would then disagree about where a caret may stand. A pinned table with a version number is a table you can put in a golden.

Grapheme-safe truncation supersedes scalar truncation for anything a person reads; scalar truncation remains the rule at the kind's cap, because that boundary is about storage validity — never split a scalar, never emit invalid UTF-8 — rather than about what a reader sees.

#Diff and patch

fluxvariant Edit { Keep(len: num) | Ins(s: string) | Del(len: num) }   // lengths in GRAPHEME clusters

edits = txt.diff(prev, next, 400)          // vec(Edit, K) — bounded by the declared maxD
back  = txt.patch(prev, edits)             // total; `back == next` when the diff was exact

The algorithm is Myers, bounded by a declared maxD. Beyond that bound it does not fail and it does not run longer: it falls back to a coarse result in the same variant — one Del of the old text, one Ins of the new — with a diagnostic. txt.diff is therefore total by construction, and every consumer handles one shape. txt.patch is total. txt.diffLines(a, b, maxD) shares the routine at line granularity.

The tie-break inside the longest-common-subsequence search is one canonical choice, pinned, with a golden — because two equally good diffs are two different byte outputs, and byte-identity does not accept "equally good". Consumers are the ordinary ones: revision history, the "changes" gutter, and optimistic-UI reconciliation when the server's answer arrives.

#The search stack

Search composes on collections; this pillar supplies the text-side pieces, all bounded and all pinned:

Piece Signature Notes
Tokenizer search.tokens(s, caps) -> vec(string, N) UAX #29 word boundaries plus pinned stopword tables
Fuzzy match search.fuzzy(q, s, maxDist: lit) bounded Levenshtein → record{ hit: signal ; dist: num }
Subsequence search.subseq(q, s) the command-palette match → record{ hit: signal ; score: num }
Prefix lookup m.range(lo, hi, k: lit) a range scan on the ordered Map — no trie kind exists, and none is needed
Ranking search.bm25(postings, stats, q, k: lit) deterministic scoring, stable tie-break by document id
Highlight spans search.spans(q, s, caps) grapheme-indexed spans, feeding the render

The indexes are ordinary bounded collections, and an index is an ordinary record holding them — three fields, one per question you can ask of it:

Field Kind Answers
terms Map(Token, Set(DocId, D), T) membership, and — because the Map is ordered — prefix
postings Map(Token, vec(record{ doc: DocId ; tf: num }, N), T) which documents, and how often
stats record{ n: num ; avgdl: num ; docLen: Map(DocId, num, D) } the corpus norms the ranker divides by

Both bounded calls take their result cap as the named argument k, exactly as the Map's own range does in collections — the count is a declared ceiling on the answer, not one more positional number to miscount:

fluxdef query(idx, q) = {
  hits: search.bm25(idx.postings, idx.stats, q, k: 20),   // vec(record{ doc, score }, 20)
  head: idx.terms.range("flu", "flv", k: 10)              // the typeahead window, O(log N + k)
}

Stopword tables are per-locale, with en as the base — the same uniform treatment i18n gives every table. A stemmer exists as an explicit, pinned variant, off by default: stemming changes what a query means, and that should be a decision rather than a default.

Open decision. Stemmer languages beyond the first two, and whether the stopword tables are owned by this pillar or by i18n.

#Validators — the catalogue that replaces patterns

Every validator is a predicate over a fixed grammar, and the set of them is a closed catalogue — never a pattern the caller supplies.

Validator Grammar
valid.isEmail(s) the WHATWG email grammar
valid.isUrl(s) the URL grammar (the same one the URL codec uses)
valid.isPhone(s) the E.164 shape
valid.luhn(s) the checksum
valid.isSlug(s) the slug shape
valid.inRange(x, lo, hi) a numeric bound
valid.matches(s, fmt) a named format, drawn from a closed variant
fluxvariant DateFmt { Iso8601 | Rfc3339 | Ymd | Dmy | Mdy }
variant NamedFormat { Date(f: DateFmt) | Hex | Base64 | Uuid | Iban }
variant Rule { Format(NamedFormat) | Email | Url | Phone | Luhn | Slug | InRange(lo: num, hi: num) | Required }

DateFmt is itself a closed enumeration of pinned date shapes — never a user-supplied pattern string, which would be a pattern language smuggled in through a parameter. The catalogue grows by addition: a new entry is a new pinned routine with a golden. It never grows a runtime grammar.

fluxvalid.matches(s, "^[a-z]+$")    // ✗ [ErrArg] — `matches` takes a NamedFormat, not a pattern
re.match("(a+)+b", s)           // ✗ [ErrUnbound] — no such name: there is no regex engine

Forms are validated field-wise. valid.form(form, rules) takes a record whose fields parallel the form's, each carrying a Rule, and returns a record that parallels them again, each field an Ok or an Err. Required is a presence check — the field is non-na and non-empty — and it is evaluated before the value predicate, so a missing field reports "missing" rather than "malformed". Every other arm names one of the predicates above. A Rule is data, not a function value: the descriptor is closed, which keeps the no-arrow discipline intact and lets the editor show the rule set as a table.

fluxvariant Check { Ok | Err(reason: string) }

def errText(v) = match v { Ok -> "" ; Err(r) -> r }       // renders one field's verdict

def validate(form) =
  let rules = { email: Email, age: InRange(13, 120) } in   // one Rule per field
  valid.form(form, rules)                                  // { email: Ok, age: Err(reason) }

note = errText(Err("too young"))                           // the message a field renders under itself

The per-field record is exactly what a text input or a form widget consumes to render its own error — which is why validation never needs a side channel.

#What is excluded, and why the ban is satisfiable

There are no regular expressions. Not "discouraged" — no name in the language evaluates one.

Why they are excluded. A regular expression is an unbounded computation described by data. Its cost is not a function of the input's declared cap but of a pattern that arrives at runtime, and a backtracking engine's worst case is catastrophic on inputs that look ordinary. A language whose central promise is that every program terminates within a budget the compiler can state cannot admit a construct whose budget is written by whoever supplies the pattern. This is the same reason there is no filter that shrinks a vector and no unbounded queue: the exclusions are one exclusion, applied consistently.

A ban is only honest if the work it forbids can still be done. This one is replaced from two sides at once:

  1. Validation is the named, bounded, deterministic catalogue above. You do not write a pattern for an email address, a URL, a UUID or an IBAN — you name the format, and the grammar behind the name is fixed, pinned and golden-tested. If a format is missing, the answer is a catalogue entry, not a pattern language.
  2. Structure never needs parsing, because it arrives already typed. A payload is decoded against the schema the application declared (Json(Trade), Md, Csv, a URL form), and a broken required field surfaces as DecodeError(field, reason) — never a silent na that poisons a computation three hops later. See net.

Between them, the cases that usually reach for a pattern — "is this a valid address", "pull the fields out of this body", "check the shape of this identifier" — are covered by constructs whose cost is a number written in the source.

Locale-dependent case and collation are also excluded from the core. upper and lower are locale-invariant, and there is no < on strings. That work exists — with an explicit locale and pinned tables — in i18n, so a computed value never depends on who is reading it.

Open decision. One door is left ajar, and the plan describes it without walking through it: a pattern that is a compile-time literal could be compiled at build into a pinned automaton (linear time, no backtracking, capped size) — at which point it is not a regular expression in the A12 sense but sugar over the fixed-grammar discipline, because the constant pattern is a fixed grammar and the automaton is the pinned routine. If a named consumer ever justifies it, it would arrive as re.match(litPattern, s) and re.find, literal-only forever. A pattern that arrives at runtime, or through data, stays excluded permanently. That is the actual wall.

#See also

↑ contents

i18n — locales, messages, collation

Post-v1. The i18n pillar is a sealed, additive design. It opens the message-catalogue seam the APP plane holds reserved, and adds no sort, no grammar symbol and no new arrow to the language.

An application that speaks one language is a prototype. Making it speak many is usually where a codebase acquires its most durable class of bug: a number that reads differently depending on who is looking at it, a plural that is correct in the language the developer happened to think in, a sort order that changes when a translation ships, a right-to-left name that reorders the punctuation around it.

Flux takes all of that seriously and refuses exactly one thing: it will not let any of it reach a computed value. A locale decides how a number is rendered and how two names are ordered. It never decides what a number is. Everything on this page follows from that sentence.

Reading the examples. Two conventions are in use below, and both are sanctioned. A line marked ✗ is often a bare expression fragment — Flux has no expression-statements, so it illustrates a kind rule rather than a program. And a member of an app block (update, view, subs) is sometimes shown on its own, since a member is only legal inside its block. Everything else is a complete statement that parses as written.

#A locale is a value

locale is an opaque string key — "fr", "en-GB", "ar" — delivered by the host as an explicit input: an APP-plane input, or a pinned entry in the replay context. It is never an ambient per-visitor default that a computation can reach for.

fluxloc = input("en", title: "Locale")           // explicit, and pinned into the replay input set
def caption() = fmt.duration(4800000, loc)   // a RENDERING — "1 hr 20 min" · "1 h 20 min"
plot ema(close, 20)                          // the SERIES cannot depend on a locale at all

The number in that second line is the same number in every locale. Only the string changes.

Why the locale is never ambient. This is the time-zone rule, verbatim. A calendar accessor lives in the ANALYSIS plane, so if the chart's zone were an ambient per-visitor setting, the author, the compiled module and the server re-executing the script would each compute a different dayOfWeek for the same bar — and a script's output would depend on who opened it. Pinning the tables closes the drift; it does not close the question of which zone the default resolves to. So the zone is either an explicitly pinned input or an explicit argument. A locale is the same kind of hazard with a wider blast radius, and it gets the same answer: an explicit value, in the replay input set, or nothing.

Locale negotiation — a visitor's preferences against the locales an app declares — is host chrome. The application never runs that algorithm; it receives the resolved value. And for anything scored, replayed or verified, the resolved locale is frozen into the replay inputs alongside the seed and the table versions.

#The tables are pinned

Every locale-dependent behaviour reads a pinned, versioned CLDR subset. Not the platform's internationalization library — a table with a version number that ships inside the build.

Table Decides Read by
Plural rules which variant of a message a count selects t, fmt.relTime, fmt.duration
Number symbols decimal separator, group separator, digit shaping fmt.num, fmt.pct, fmt.price
Date/time patterns field order, month and weekday names fmt.time
List patterns "a, b and c" against "a, b et c" fmt.list
Collation tailorings the order of two strings in a locale coll.sort, coll.topK, coll.fold
Script and RTL metadata the base direction of a locale textDir, the layout
Bidi (UAX #9) how mixed-direction runs are reordered the host renderer

A table bump is a new pinned version, which is a new build hash. It is never a silent drift underneath a frozen application — the failure mode where a routine library update quietly changes what an app renders, or how it sorts, has no way to happen here.

Delivery is split, once. The en subset is embedded in the runtime; every other locale's subset is a lazy, versioned bundle asset, loaded per declared locale. This is deliberately unlike the time-zone database, which is embedded whole: only en has that platform status, and the next section says exactly why, and exactly how far that privilege goes.

#The message catalogue

The v1 limit — application strings live in the source — is lifted by a seam that keeps the strings out of the script entirely.

fluxapp reader {
  capabilities: [ i18n:catalogue ]

  init(p)        = { locale: p.locale, unread: 3 }
  update(m, msg) = match msg {
                     Locale(l) -> { model: m with { locale: l }, cmds: [] }
                   }
  view(m)        = col { text(t("inbox.unread", { count: m.unread })) }
  subs(m)        = [ OnLocale(Locale) ]
}

The script names a key and hands over arguments. It never sees a message, never concatenates one, and never parses one.

Why the catalogue is host-held. A script that carried its own strings would have to do something with them — select a plural form, interpolate an argument, pick a gendered variant — and that means parsing a message format at runtime, which A12 forbids for the same reason it forbids every other runtime grammar. Handing the catalogue to the host moves the parse to load time, through a fixed, pinned grammar, once. What the script holds is a key: a value with no structure to interpret.

Both failure modes are total. Neither throws, and neither returns an empty string:

Failure Behaviour
Missing key the declared fallback chain (fr-CA → fr → en), then the key itself, verbatim, plus a diagnostic
Placeholder with no matching args field, or a wrong-kind value the literal placeholder token is rendered, plus a diagnostic
Extra args fields ignored

Where the catalogue is available at build time — a first-party app, its own strings — the placeholder set is compile-checked against every t(key, args) call site, and an unknown placeholder is [ErrInput] before the app ever runs. Third-party bundles that load lazily fall back to the runtime rule above. A translator's typo degrades one label; it does not take down a view.

#A message is a selection tree, not a concatenation

Messages use MessageFormat 2: declarations, .match selectors for plural, ordinal and general selection — gender lives here — and placeholders with formatting functions. A message is written by a translator and looks like this, in the catalogue, never in your source:

.input {$count :number}
.match $count
one  {{You have {$count} new message.}}
*    {{You have {$count} new messages.}}

Why selection cannot be done by gluing strings together. "You have {n} new messages" is not one message with a hole in it. In English it is two forms. In other languages it is three, four, or six, and which one applies is a function of the number that no application should be encoding. Gender is worse: it is not a prefix you can concatenate, because in many languages it changes agreement across the whole sentence. Concatenation forces every translator into the grammar of the language the code was written in, and produces text that is correct nowhere else. Putting the selection inside the message — in the catalogue, where the translator works — lets a message be restructured for its language without a single line of code changing.

Three properties keep this deterministic:

#Locale-aware formatting

fmt.* gains an explicit locale. The locale-invariant forms remain, and they stay the default in the ANALYSIS plane.

What Locale-invariant form Locale-aware form
Number fmt.num(x) fmt.num(x, locale)
Percentage fmt.pct(x) fmt.pct(x, locale)
Price fmt.price(x) fmt.price(x, locale)
Date and time fmt.time(t, pattern, zone) fmt.time(t, pattern, zone, locale)
Relative time fmt.relTime(t, ref, locale)
Duration fmt.duration(d, locale)
List fmt.list(v, listType, locale)

The bottom three rows are net-new surface, and they have no locale-invariant form for a good reason: there is no locale-invariant answer to "three hours ago". They are the humanization pair plus the list joiner, and they ride the plural rules like everything else — the unit choice (seconds → minutes → hours → days → weeks → months → years) is window-bounded, not open-ended.

fluxdef posted(ts, ref, loc) = fmt.relTime(ts, ref, loc)     // "3 hours ago" · "il y a 3 heures"
def spanOf(d, loc)       = fmt.duration(d, loc)          // "1 hr 20 min" · "1 h 20 min"
def stamp(ts, loc)       = fmt.time(ts, "d MMM y", "UTC", loc)

Currency rendering composes the pinned symbol table with the quote tag an amount already carries, so a price[BTC, EUR] renders with the right symbol in the right position for the locale without anyone passing the currency twice. See asset & currency.

Every one of these is a pinned routine — interpreter ≡ compiled module ≡ server — with a golden per routine and locale family. Because the locale is a parameter rather than a mode, two locales can never race inside one oracle run: the golden for ("fr") and the golden for ("ja") are two independent, reproducible facts.

Why the number never becomes locale-dependent. fmt.num(x, loc) returns a string. It does not change x, and there is no "current locale" that arithmetic could consult. This is the whole trick, and it is worth being blunt about what it rules out: an application cannot branch on a formatted number, cannot compute with one, and cannot feed one back into analysis, because it is text — and text is not plottable, not ordered, and not numeric. The locale reaches the rendering and stops there.

#Collation — an order without an operator

The frozen ordering machinery does not admit string keys. sortBy's key function must return an ordered scalar, and string is excluded from ordering (there is no < on strings, by A12).

fluxbyName = vec.sortBy(rows, (r) -> r.name)   // ✗ [ErrArg] — a `string` is not an ordered key kind
"a" < "b"                                  // ✗ [ErrDim] — no ordering on `string`, ever

So collation ships as its own pinned combinator rather than as a key-function trick:

fluxrows   = Vec.of([{ name: "Ötzi" }, { name: "Adam" }, { name: "Zoë" }])   // Vec(record{ name: string }, 3)
loc    = "de"
needle = "STRASSE"
ranked = coll.sort(rows, (r) -> r.name, loc)        // the pinned CLDR order for an explicit locale
top10  = coll.topK(rows, 10, (r) -> r.name, loc)    // the same order, bounded result
folded = coll.fold(needle, loc)                     // case-insensitive matching → a `string` value

The extracted string is data. The order applied to it is the pinned CLDR collation order for the explicit locale, with tailorings versioned per locale, and with the same absent-last, stable-by-index policy that sortBy and topK already use. The routine joins the pinned set and carries its golden.

Why this is not a loophole. General string ordering stays inexpressible: < on strings and sortBy on a string key remain errors after this pillar ships. What exists is one named, locale-explicit, pinned order — precisely the shape the calendar already has, where arbitrary time-zone arithmetic does not exist but the named, pinned IANA tables do. The A12 exclusion of locale-dependent collation rested on two grounds, and both are answered rather than waived: the determinism ground is dissolved by pinning (the locale is explicit, the tables are versioned), and the totality ground already held, because a string is bounded — bounded input, bounded key, total order.

The third of those is case folding, and it answers a different question from the other two: not what order, but do these match. coll.fold(s, locale) reads the same tables and returns a string. Folding produces data, and equality on strings is already admitted as bit-equality — so a folded comparison needs no new operator either, and matching "STRASSE" against "straße" stops being a special case somebody has to remember.

Open decision. The key-derivation cap for very long strings — the bounded-input, bounded-key policy — is left open by the plan.

#Right-to-left and bidi

textDir(locale) -> dir returns the existing dir kind: 1 for left-to-right, -1 for right-to-left. It is consumed as data — the host flips rail and panel sides from tokens, and an application reads the value only for content decisions.

fluxdef isRtl(loc) = textDir(loc) == -1      // `dir` is discriminated by comparison, never by `match`

This is deliberately not an ANALYSIS presentation of dir — whose only chart channels remain marks and bar colouring — so the pillar adds zero new kinds.

Three things then follow, and all three are host-side:

#en is the base locale; nothing else is privileged

en is embedded in the runtime as the fallback terminal — the only locale with platform status, and it has that status for exactly one reason: a fallback chain needs somewhere to stop.

Every other locale is a uniform citizen. fr, es, it, de, ja, ar — all of them are delivered the same way: versioned bundle assets, lazy per declared locale, with no special-casing anywhere in the machinery. An application declares its locale list in the manifest, where it is inspectable before installation, exactly like a capability.

That uniformity is a design commitment, not a coincidence of the current bundle. The first-party product happens to ship French first because its audience is French — through the identical mechanism any locale uses, with no shortcut available to it that a third-party app could not take. A platform that grew a privileged second locale would grow a second code path with it, and the second code path is where the divergence lives.

Open decision. The exact cut of which tables the embedded en base must carry, against what even en may lazy-load. The collation root is the heavy candidate; the plan does not settle it.

#Reacting to a locale switch

fluxsubs(m) = [ OnLocale(Locale) ]

Sub OnLocale(C) delivers a locale change as a journaled message, carrying the constructor that routes it into update. The application re-renders. Nothing else changes.

OnLocale is an additive opening of the closed subscription catalogue — the same extension mechanism the network subscriptions use — and not a special case bolted onto the side of it.

Why a message rather than an ambient re-read. The journal is the single source of truth: an application's entire behaviour is reconstructible by folding its messages. A locale switch that mutated an ambient global would be an input that never entered the journal, and the same session would then re-fold to a different view. As a message, it is recorded, replayable, and testable like every other edge — and the fact that the view changes while the model's numbers do not is visible right there in the fold.

#Determinism, in three rules

Everything above collapses into three sentences, and they are the reason the pillar looks the way it does rather than the way an internationalization library usually looks.

1. A locale affects rendering and ordering. Never a computed value.

A locale MAY decide A locale may NEVER decide
how a number is rendered (fmt.num(x, loc) → a string) the value of x
the order two names appear in (coll.sort(…, loc)) the result of any numeric comparison
which plural form a message takes which branch an if takes in ANALYSIS
the base direction of the layout a plotted series, or a signal

2. Every table is pinned and versioned, so two engines agree — and so a table update is a build change you can see, rather than a behaviour change you cannot.

3. The locale is an explicit value, so it lives in the replay input set. A re-execution reproduces the same rendering, and a server verifying a result is looking at the same text the user saw.

#What this costs, and what it does not

It costs a capability row, a pinned table family, one pinned combinator for ordering, and one subscription. It adds no sort to the lattice, no symbol to the grammar, and no second arrow: locale is a string value, t / coll.* / fmt.* are pinned routines behind prelude definitions, and textDir reuses the dir kind that already exists.

The firewall is untouched. Locale-dependent output is presentation and APP-plane work; ANALYSIS keeps the locale-invariant fmt.* defaults, and a locale reaches it only as an explicitly pinned input.

Post-v1. Catalogue authoring tooling — the editor integration and the translation export format — follows the runtime, as tooling does.

Open decision. Whether a locale-aware slug() pulls transliteration tables into the core, or keeps them out of it.

#See also

↑ contents

color — colour as a value

Colour sits on a fault line. It is presentation — it belongs to the theme, to the visitor, to the eyes — and yet a colour derived from data is a decision the analysis made, and a decision the analysis made must be reproducible to the byte, or replay drifts and a server can no longer re-derive what a client claims to have computed.

Flux resolves this with a clean split rather than a compromise. The per-bar colour decision is analysis: deterministic, replayable, inside the oracle. The mapping to pixels is presentation: theme-aware, per visitor. Determinism lives where the data lives; theme lives where the eyes are. Everything on this page follows from that one line.

This page covers the color kind end to end: how a colour is represented, how you construct one, how two colours interpolate, the channels through which a colour reaches the chart, and the boundaries the design deliberately keeps closed. Some samples below are negative — a fragment followed by and an error code is an illustration of a rule, not a program.

#The value — a u32 RGBA carried as an exact f64 integer

A color is RGBA8, packed 0xRRGGBBAA (red in the high byte, alpha in the low byte, straight alpha), and carried through the dataflow as a non-negative f64 integer.

That is not a compromise; it is exact. A u32 is below 2³², and every integer below 2⁵³ is represented exactly in an f64 and round-trips through one. So a colour is an f64 whose value happens to be an integer, and the consequences are entirely good:

Consequence Why it falls out
No new compute channel The whole f64 engine — const, select, na, the I7 gate over f64 columns — is reused as-is.
if c then a else b on colours Already the existing f64 select. Nothing was added for it.
color == color An exact f64 == — bit equality, → signal.
na colour The canonical NaN — distinct from every finite colour integer. At the host it means no per-bar override (transparent). It propagates through select, so totality holds.
Serialization The one place the representation shows: a colour column ships as a Uint32Array, never an f32 array. na serializes to 0x00000000.

Why the sink is Uint32Array and never f32. An f32 has 24 bits of mantissa, so it stops representing consecutive integers past 2²⁴ — an RGBA value above that would be silently rounded to a neighbouring colour. The colour would still look plausible, which is the worst kind of bug. The column ships as u32, and the class of bug does not exist.

The packing order 0xRRGGBBAA matches the CSS #rrggbbaa notation, which is convenient at the boundary but is otherwise an internal, pinned detail: the host extracts channels explicitly. What I7 cares about is not the layout — it is that the interpreter and the compiled module compute the same f64 integers, which they do.

#The absent colour

na is a colour like it is a number: the value that is not there. On a colour column it means no per-bar override, so the bar keeps whatever the chart would have drawn. That makes "colour only the bars I care about" an ordinary expression rather than a special mode:

fluxfresh = barssince(close cross_up ema(close, 50)) < 5   // signal
color bars: if fresh then up else na                   // colour the fresh bars; leave the rest alone

The same rule covers warm-up at no cost: on the bars where an indicator has not yet filled its window its value is na, so a colour derived from it is na, so those bars are left un-overridden. Nothing throws, nothing is undefined, and no branch had to be written for it.

#The analysis / presentation split

Two channels leave the analysis plane, and choosing between them is the first decision you make.

#dir — the semantic channel, and the idiomatic one

dir is the kind {-1, 0, +1}. Analysis answers the semantic question — is this bar up, flat or down? — and the host maps that answer to the theme's up / neutral / down colours.

fluxst = superTrend(10, 3)          // sourceless (it reads high/low/close) → record{ st: price, dir: dir }
color bars: st.dir              // the host maps {-1, 0, +1} to the theme

This buys two things at once. Theme-awareness without breaking determinism: the analysis value is a dir, not an RGBA, so what the oracle byte-compares is the decision, not its appearance. And accessibility for free: because the host owns the mapping, it can map dir to a colour-vision-deficiency-safe palette — roughly one man in twelve cannot separate red from green, and a script that had hard-coded red and green would have made that unfixable.

dir is the idiomatic way to colour bars. Reach for an explicit color when you need a colour the theme cannot name.

#color — the explicit channel, for custom colours and gradients

An explicit colour in analysis is legal, and it is deterministic because of where its values can come from: they are pinned constants (up, down, neutral) or computed by pinned maths (mix, rgb of computed channels). Both are inside the oracle and covered by I7.

fluxcolor bars: if close > ema(close, 200) then up else down   // pinned constants; the host remaps to theme

What is not legal is a colour that varies with the viewer. A theme token resolves against the visitor's current theme, which makes it an ambient, per-visitor input — the same class as reading the wall clock or the screen:

fluxcolor bars: if close > open then token.bull else token.bear   // ✗ [ErrFirewall] — a token resolves per visitor
color bars: if minute(now()) > 30 then up else down           // ✗ [ErrFirewall] — now() is a presentation symbol

Theme is a host remap of pinned or semantic values at render time, never an ambient input into analysis. Tokens are for the plane where the eyes are:

fluxtriangle { at: (bar.i, low), r: 6, fill: token.bull }   // CANVAS — theme-aware, and correctly so

Why the firewall is drawn here and not one step later. If a theme colour could enter analysis, then the value a script produced would depend on who was looking at it. Two visitors would compute different bytes from the same data; a golden would depend on a theme; a server re-deriving a client's run would have to know the client's colour scheme to agree with it. The line is drawn where the data is, so that the data means the same thing everywhere.

#Choosing a channel

What you want What you write
bars coloured by a semantic state — trend direction, the side of a stop a dir; the host maps it to the theme, and to a safe palette for colour-vision deficiency
a brand colour, a scientific palette, a gradient an explicit color — pinned constants or pinned maths
a theme colour in the UI or on the canvas a token.*
a theme colour in analysis nothing: it is [ErrFirewall]. Emit a dir and let the host map it — that is the same picture, computed on the right side of the line

#Construction — the color.* surface

Every constructor is an ordinary call of a closed set of host-vetted functions. There is no grammar change anywhere in this pillar: no colour literal, no new token, no re-verification of the frozen grammar.

Pinned semantic constants, versioned like the pinned maths, aligned with the chart theme: up, down, neutral.

Constructor Arguments Notes
rgb(r, g, b) r, g, b ∈ [0, 255] clamped deterministically
rgba(r, g, b, a) plus a : ratio ∈ [0, 1] straight alpha
hsl(h, s, l) / hsla(h, s, l, a) h ∈ [0, 360), s, l ∈ [0, 1] HSL → RGB is piecewise-linear — zero transcendentals, so determinism is trivial
hex(s) "#rgb", "#rrggbb", "#rrggbbaa" a string literal, parsed at compile; a malformed literal is na plus a diagnostic
withAlpha(c, a) / fade(c, a) a channel replace — cheap
lighten(c, amt) / darken(c, amt) perceptual: they move OKLab lightness
mix(a, b, t) t : ratio the perceptual blend — see below
fluxbrand = rgb(34, 211, 163)         // a const-folded colour node
mint  = hex("#22d3a3")            // parsed at compile — the same node
ghost = up.withAlpha(0.25)        // straight alpha, not premultiplied
def ramp(t) = mix(down, up, t)    // a gradient — blended in OKLab

A constructor whose arguments are all constant const-folds to a const colour node. A constructor with dynamic arguments lowers to a deterministic runtime operation — and is byte-identical between the interpreter and the compiled module, like any other kernel.

#Theme tokens

token.bull, token.bear, token.grid are the theme's colours. They are theme-aware — they resolve against the visitor's current theme — and they are the right default for anything semantic in the UI and the canvas. That same property is what keeps them out of analysis (previous section). An explicit color is, by contrast, theme-blind by the author's choice: that is what you want for a brand colour or a scientific palette, and it is what you do not want for "the bullish one".

#The pinned palette scales

Scientific and categorical palettes come from a host-pinned, versioned palette table — at the same rank as the currency-symbol and Unicode tables — sampled by a closed set of calls:

Call What it gives
color.seq(scheme, t) a perceptual sequential scheme sampled at t : ratio
color.div(scheme, t) a diverging scheme
color.cat(scheme, i) a categorical, perceptually balanced entry at index i
color.quantize(scheme, t, n: lit) the same, quantized into n discrete steps

The scheme names a row of that closed table (the sequential family — viridis, magma — and the diverging and categorical families beside it). Like hex, it is checked at compile: a palette lookup is a compile-time selection, never a string interpreted at run time. These are the first-party colour scales that the viz.* encoding channels use — see display.

Post-v1. Hue-true scheme generation in OKLCH — and the withHue / withChroma / withLightness family — waits on the pinned-maths completion lot that delivers atan2 and the sine-cosine pair. OKLab Cartesian (below) ships first and needs neither of them.

That gate is narrow, and two neighbouring families sit outside it. Both are pure channel arithmetic, and both need only pow — which is pinned, and available:

Neither waits on the transcendental lot, and it is worth being explicit about that, because the natural assumption — "they are colour maths, so they are behind the colour-maths gate" — is wrong in a way that would defer two useful families for no reason.

#Interpolation — OKLab, perceptually uniform, deterministic

mix(a, b, t) blends two colours in OKLab. The pipeline, per channel:

sRGB8 → [÷255] → linear (gamma decode, pow 2.4) → LMS (3×3 matrix)
      → L′M′S′ (cbrt) → OKLab (matrix)         → lerp L, a, b by t
      → the exact inverse (cube is x·x·x)      → linear → sRGB (pow 1/2.4) → [×255, round]

Why not a straight lerp in RGB. Interpolating red to green in raw sRGB passes through a muddy brown, because sRGB is not perceptually uniform: equal steps in its channels are not equal steps to the eye, and the midpoint of two saturated colours lands somewhere dark and grey. In OKLab, equal steps are perceptually equal, so a red-to-green ramp stays clean the whole way across. A gradient is a communication device; a gradient with a muddy middle is a broken one.

Determinism. Every pow in that pipeline routes through the pinned maths library — the same one an ema's logarithm uses — so it produces identical bits in the interpreter and in the compiled module. I7 holds through a colour blend exactly as it holds through a moving average.

Three details of the pipeline are load-bearing, and each one is a decision:

A gradient is therefore an ordinary expression over data, and it is in the oracle like any other:

fluxt    = norm(volume)                           // ratio in [0, 1] — normalized against its own range
heat = mix(neutral, up, t)                    // an OKLab ramp, one colour per bar
plot close { color: heat }                    // the series, coloured by relative volume

Three lines, no palette object, no colour-space bookkeeping, and a byte-identical result on every machine that runs it.

#The output channels

There are three ways a colour reaches the chart, and only three:

Channel Accepts What it produces
color bars: … signal | dir | color a per-bar colour column on the candles
{ color: … } inside a plot block a color per bar a parallel colour column attached to that plot's sink
a dir column the host maps {-1, 0, +1} to the theme's up / neutral / down

The { color: } channel is what makes a coloured histogram fall out of the ordinary algebra rather than out of a special case:

fluxm = macd(close, 12, 26, 9)                             // record{ macd, signal, hist : level }
plot m.hist { color: if m.hist > 0 then up else down }

In v1 the { color: } block channel is plot-only. On a fill or a mark block it is an error rather than a silently ignored property:

fluxbb = bollinger(close, 20, 2)
fill bb.upper..bb.lower { color: up }   // ✗ [ErrArg] — the { color: } channel is plot-only in v1

Why an error and never a silent drop. A property that is accepted, ignored and never drawn is a bug you find by staring at a chart and wondering. The compiler knows the channel does not exist on that block; it says so.

#How a colour reaches the chart engine

The chart carries a per-bar colour input, and it has three arms that mirror the three channels above:

Per-bar colour on an overlay-scale series — a moving average, a price line — ships as coloured segments.

Post-v1. Colouring a mark glyph (a dot) is deferred, as is colouring a fill band. Under an MTF lock — where the geometry belongs to a series on another timeframe — a per-bar colour falls back to the base colour; per-bar colour by the locked timeframe is a follow-up.

#The I7 gate — a colour is data, so it is checked like data

The verification gate compares every sink column at the f64 bit level, and it is deliberately agnostic to what a column means. A colour column is u32-in-f64, so it is compared exactly like a price column: the gate needed no change to accept colour, and it will not accept a colour that differs by one bit between the two engines.

What that costs, concretely: every colour operation must emit identical bits on both sides (const and select are free; pack and the OKLab path route through the pinned maths), and the corpus that exercises the gate is deliberately hostile — channels at 0 and 255, alpha at both ends, na, and out-of-gamut inputs to mix.

This is the whole reason a colour is not a "style". A style would be outside the oracle, and a colour programme would be unverifiable. A colour is data, and it is byte-checked like every other value.

#Totality and the lattice

color is a flat categorical sort. It is not on the numeric spine, and the lattice enforces that:

fluxshade = up + 1   // ✗ [ErrDim] — a colour is not a number: there is no meaning to add to it

The only operator defined between two colours is == (bit equality, → signal). Colour carries no order, so < and its relatives do not type-check on it either. And a colour is not line-plottable:

fluxplot up   // ✗ [ErrPlot] — a colour is not a series; it is consumed by `color bars:` or the { color: } channel

Every colour operation is total: channels clamp, na propagates, and there is no undefined behaviour to hit. That is what lets a colour flow through select and na without a single special case anywhere in the engine.

#Deliberate boundaries

Boundary Status
A free CSS colour stringurl(…), expression(…), embedded HTML Never. No constructor produces one and the lexer carries no colour literal, so it is structurally inexpressible — not filtered, not sanitized, not reachable.
A #rrggbb grammar literal Post-v1. A new token would force a zero-conflict re-verification of the frozen grammar; hex("#…") covers the need today.
OKLCH and hue interpolation Post-v1. Waits on the pinned-maths completion lot (atan2, sine-cosine, cbrt); OKLab Cartesian ships first.
Colouring a fill band or a mark glyph Post-v1. In v1 the runtime colour consumers are color bars: and the plot { color: } channel.
Theme and colour-vision-deficiency palette definition A host concern by design — analysis emits dir or pinned colours, and the host maps them.
Wide gamut, HDR, premultiplied alpha Out of scope. 8-bit straight-alpha sRGB ships.

The first row is the one worth dwelling on, because it is the only genuine injection surface a colour system has. The reason a free CSS string cannot reach the renderer is not that a sanitizer rejects it. It is that there is no way to say it: the constructors are a closed set, none of them takes an arbitrary style string, and the grammar has no colour literal for one to hide in. A boundary you enforce with a filter is a boundary you will eventually get wrong. A boundary you enforce with the grammar is one you cannot.

For richer backgrounds, the closed structural sort paint — a variant over Solid, Linear, Radial and Texture — is the sanctioned surface, and its texture arm carries an asset key that the host resolves under an allowlist, never bytes from the script. See display.

#See also

↑ contents

display — scenes, panes and the render targets

Post-v1. The display pillar is fully designed and additive to the frozen core.

One word covers a chart, a dashboard, a data visualization, a 3-D view and a game: a scene. A scene is a pure function of the model, and it is a value — a tree of vetted primitives you can bind, return from a function, and pass to a window. The host turns that value into pixels; the language never touches a pixel.

That is not a stylistic choice. It is what lets a game in a pane be replayed frame-exactly, a chart overlay be goldened without a screenshot, and an animation be beautiful without ever touching a value that a verdict depends on.

#The presentation theorem

The geometry of a scene is a pure function of the Model. It is therefore deterministic, byte-identical, bounded, and inside the replay oracle. The painting of that geometry — the GPU, the compositor, per-frame signals — is non-deterministic with respect to the world, and is therefore host-only, outside the oracle, and firewalled from the Model.

Everything else in this page follows from that sentence. A pane may read a live exchange feed, an order book, a game's state; an indicator can never be read back by any of it.

The two strata of a scene
Figure — geometry is byte-identical; cosmetics are pretty. Neither contaminates the other.

#The two strata

Every scene splits, structurally, into two layers:

(a) retained geometry (b) per-frame cosmetics
What position, shape, size, token colour, order — as a function of the Model glow, pulse, parallax, shimmer, morph particles
Driven by Model values, tween/spring settle values, seeded randomness wall-clock, screen space, unseeded randomness
Runs where in the oracle — the same bytes on every engine on the host compositor — zero JavaScript per frame
Replayable yes no, and it never needs to be

The routing is decided by the kind of the signal, which the compiler already knows. A signal derived from the Model is stratum (a); a signal touching now(), screen.* or unseeded randomness is stratum (b) — and reading one of those from analysis is [ErrFirewall], as is smuggling one into a Model field that a verdict reads.

#The scene value

fluxdef overlayOf(m) = scene {
  when volume > sma(volume, 20) * 2 :
    dot { at: (bar.i, high), r: 4, fill: token.spike, glow: throb(0.4) }
  when ema(close, 9) cross_up ema(close, 21) :
    triangle { at: (bar.i, low), r: 6, fill: token.bull }
}

// …and, inside an `app` block, the view mounts it:
//   view(m) = chartView(chartId: "main", overlay: overlayOf(m))

A scene is a value of kind ui. Note what is not in that sample: no loop over bars (per-bar signals are evaluated per bar, implicitly), no animation API (glow is a signal like any other), and no way to write back into the analysis it reads.

The glow is stratum (b) — it goes to the compositor. The position is stratum (a) — it is in the oracle. The boundary runs through the same primitive, and the compiler knows which side each property is on.

#Primitives, composition, layout

The primitive set is closed and vetted: dot, circle, ring, rect, square, triangle, poly, line, path, text, image, svg, sparkline, backdrop. They share one property model — at, size/r/w/h, rotate, fill, stroke, width, opacity, glow, blend, z, life, color, trail, paintOrder.

Three composition combinators, all bounded:

Meaning Bound
group { … } transform / blend / clip a subtree — the universal internal node
repeat n as i { … } instancing: n shapes parameterized by the index n is const-folded
for x in coll -> child a comprehension over a bounded collection the collection's declared capacity

There is no integer iterator: for i in range(n) does not exist, because n is not a collection. The bound always comes from a declared capacity — which is what makes the instance budget computable at compile time.

Layout reuses the frozen ui containers (col, row, grid, stack, tabs, scroll, panel, the application rails) and adds wrap/flow, plus two data widgets: virtualList (the windowed feed — the host materializes only the visible rows) and tableView (a data grid rendering a Table's columns directly, never materializing rows).

#Style

Every visual property is a signal, so there is no separate animation API. Colours come from two places:

Rich fills are a closed variant:

fluxrecord Stop { color: color ; at: ratio }

variant paint {
  Solid(c: color) | Linear(start: Stop, to: Stop, angle: angle)
  | Radial(center: Stop, stops: vec(Stop, 8)) | Texture(assetRef: string, fit: fit)
}

Texture takes an asset key, never bytes — the host resolves it under asset:load, with an allowlist and a quota.

The one injection surface, closed structurally. A free CSS string (url(…), an expression, markup) is not filtered — it is inexpressible. No constructor produces one, and the lexer carries no colour literal. A malformed hex yields na and a diagnostic; an out-of-range channel is clamped by the sanitizer.

#Coordinates

A coordinate derives its axis from its kind: price → the price axis, barindex → the ordinal x axis, time → the time axis, screen.* → viewport pixels, world3D → the 3-D scene. Mixing spaces inside one coordinate is [ErrDim] at compile time. There is no such thing as a geometrically incoherent scene.

Two subtleties are worth stating precisely:

#The retained diff

A scene is a tree of keyed vnodes. It compiles once; on each logical frame the reconciler diffs f(Model) against the previous tree and re-emits only what changed. Keyed children keep their identity across reorders; events are delegated at the root.

The oracle is defined on the absolute draw-list f(Model), never on the sequence of diffs. So an engine that re-renders more, or less, or in a different order, cannot change what is replayed.

#Encoding data: viz.*

Kind-driven auto-presentation already is an encoding grammar — for a series. viz.* is its sibling for arbitrary tabular data: a library of pure functions that consume a Table from the compute pillar and return a ui value.

fluxplot viz.chart(t, { x: t.time, y: t.close, color: t.sector }, mark: Line)
plot viz.histogram(t.returns, bins: 40)
plot viz.facet(t, by: t.sector, shared: yScale, child: tile)   // small multiples on ONE shared scale
plot viz.legend(scale)                                          // derived from the encoded channels

Domain inference is a bounded fold (extent(col)), the same auto-scale the host already performs on the visible window — causal, with no look-ahead. Everything lowers to scene{}, for, primitives and arithmetic: no new grammar, no new sort.

Brushing is bounded and edge-committed: viz.brush journals a range at pointer-up, sibling views read that range from the Model, and linked cross-filtering works without hand-wiring — with no continuous stream of pointer samples anywhere near the Model.

#Drawing tools: the three host bindings

A custom drawing tool is a contribution, and the gesture is host-driven through exactly three declarative bindings:

Binding What it does
drawPreview: a shape template (line, rect, circle, poly, path) parameterized by the anchors placed so far and the live pointer. The host interpolates it as pure presentationzero messages during the gesture — and emits one journaled message at the end (pointer-up, the terminating gesture, or the declared anchor cap, which commits rather than overflows).
magnet: declared snap candidates (Ohlc, Anchors) plus a pixel radius. The host snaps before delivery: the (bar, price) your script receives is already snapped, correct on linear and logarithmic axes alike. Projection stays host-side; the script stays in data space — at the named cost below.
cursor: a host-allowlisted cursor enum, applied on hover through the picking system. Cosmetic, zero messages, outside the oracle.

Esc or a lost capture throws the template away with no message at all — nothing entered the journal, so nothing has to be undone.

What magnet: costs, named. Which candidate wins depends on the pixel radius and the viewport, so the selection is a host-side, device-dependent decision — and the (bar, price) it snaps to is journaled, which puts it squarely inside the oracle. It therefore inherits exactly the outcome-forging contract that picking carries: a verdict resting on a snapped anchor needs the server to re-derive it, or the run stays out of the shared leaderboard. What bounds the exposure is that the snap can only ever land on a value re-derivable from the Model — an OHLC of the bar under the cursor, or an anchor that already exists. It cannot conjure a price that was never there.

#Panes, targets and windows

A render target is a host resource under a capability, addressed by an allowlisted string key — never a handle the script holds. Three windows project a ui value into a target:

flux// a `ui` value: the three windows in a container — exactly what a `view` returns
col {
  chartView(chartId: "main", asset: "BTC-USD", overlay: overlayOf(m), onClick: ClickAt)
  paneView("rsi")
  sceneView(target: "pane.game", tree: worldOf(m), space: World3D, onPick: Tapped)
}

The same ui value is routed by one host renderer to whatever substrate the pane's state requires — and the script never knows which:

UiTree ─► reconcile ─► SOLID    → DOM / SVG            (crisp, accessible, the default)
                       LIQUID   → Canvas2D → texture   (deterministic capture)
                       FLOATING → texture + chrome     (shadow, refraction)
                       SPATIAL  → a quad in the 3-D scene

Downgrading a substrate under GPU load emits no message and does not change the absolute draw-list. Spatial and liquid paint the same logical geometry.

#The 3-D model

3-D is a projection, not a second language. The same scene{…} carries world3D coordinates when its window declares that space; the primitives (mesh, camera, light, material, billboard) are declarative and vetted, and shaders are host-held recipes or allowlisted catalogue keys — never WGSL text, never a lambda.

Two additive sorts serve this pillar, both flat under and opaque to match — the exact status of clock:

The chart's own 3-D mode is a client of this, not a special case — and at a camera angle of zero it is pixel-identical to plain 2-D, by construction.

#The execution model

The scene compiles once, into a bounded, deterministic draw-list serialized in linear memory. The host decodes it, sanitizes it, and paints — vector for crisp 2-D, GPU for 3-D. The module never touches a Web API.

The draw-list chain
Figure — three signal classes, three routes, and a per-frame cost of zero for the ones that move the most.

Signals are classified and routed:

Class Example Route Cost per frame
static stroke: token.grid cached — never recomputed 0
per-bar at: (bar.i, ema(close,20)) pre-allocated buffers; identical shapes instanced O(Δ bars)
per-frame, time-only glow: throb(0.4) the host compositor 0 JavaScript per frame

Three budgets, all const-folded and checked at compile time: the number of draw-list ops, the number of instances (a repeat n emits one op and n instances; an emitter draws from a host-fixed capped pool), and the worst-case GPU work of a shader recipe. Exceeding any of them is [ErrSceneBudget] at compile time — never an out-of-memory or a device reset at runtime.

#[ObsDeterminism] — determinism in observation

Deterministic output is only half of it. The other half is the half everyone forgets: wherever a channel reads presentation state and routes it back as a message, a non-deterministic value could leak into the Model — and from there into a verdict.

[ObsDeterminism]. Any subscription or channel carrying a presentation signal into the APP plane must deliver a payload that is either (i) deterministic and replayable — an ordinal index, a discrete edge, a stable key, a const, or a pinned host measurement — or (ii) presentation-tagged, and therefore [ErrFirewall] if it feeds a Model field a verdict reads. No channel ever delivers a continuous wall-clock time, a transition's progress, a transient spawn position, a continuous pick intersection, a held pointer/wheel/analog sample, or a device-variable measurement into a Model.

Its instances, each enforced where it lives:

Invariant What it guarantees
[TransSettle] a transition exposes only its terminal edge, never its progress. The edge is scheduled at a deterministic journal rank derived from the declared duration — not at the real moment the animation ends.
[TickOrdinal] OnTick journals a monotone integer index, never a wall time; dt is the subscription's declared constant. A raw frame time is presentation-tagged.
[SpawnGeom] emitter and morph particles are cosmetic by construction — a transient position is never readable by anything that feeds the Model.
[TextMetric] any text measurement entering geometry goes through a pinned host metric routine, byte-identical across devices.
[FocusMsg] every focus transition is a journaled message; a key event reaches the Model only if the journal attests focus at that rank.
[HeldFromEdges] held inputs (keys, pointer, wheel, gamepad axes) are always derived from journaled edges, never sampled freely per frame.
[PickKey] a pick delivers the key only — the continuous intersection coordinate is presentation and never reaches the Model. Gameplay decides on a key, by construction.
[SlotGeom] slot geometry (a rectangle, a resize) is presentation: it sizes a view, it never decides a verdict.
[HoverEdge] hover is reduced to discrete bar-crossing edges, or it is presentation-tagged. Its payload is fine; its cadence is not replayable.

Together with [DiffAbsolute] (the oracle is the absolute draw-list, not the diff sequence), this makes the logical draw-list replayable bit for bit: you replay (init, messages) → Model → geometry, never the framebuffer.

The honest limit. Replay proves a journal is coherent; it does not prove it is truthful. A host-pushed payload journaled as data — a pick key, a pre-computed outcome — is re-folded verbatim, because replay does not re-run the ray-cast or the kernel to attest it. So a score that depends on such an outcome requires the server to re-derive it, or the run must be excluded from a shared leaderboard. The same is true of time: elapsed time in a ranked run is host-stamped and substituted at re-fold; the client's journaled ticks are advisory.

#Transitions

A transition interpolates the rendering between two already-computed states. It is cosmetic by definition: it cannot change a value, so it cannot repaint.

fluxon switch(asset) -> morph chart over 500ms { ease: inOutCubic ; stagger: 0.3 ; surplus: collapse }
on click        -> focus(view, at: (bar.i, close), zoom: 2.0, over: 600ms, ease: outBack(1.2))

A transition driven by time is stratum (b) throughout — non-replayable, and excluded from the oracle. A transition driven by a state change has its settle value in the oracle (the geometry it lands on) while its trajectory stays cosmetic.

prefers-reduced-motion is a host fact applied at the compositor: it jumps to the settle state. Because the settle is in the oracle and the trajectory is not, the verdict is unchanged — and the terminal edge still lands at the same journal rank, so two honest clients, one with reduced motion and one without, produce the same trace.

#Input

Every signal from the world enters through one ingestion point and becomes a message.

fluxapp snakePane {
  capabilities: [ clock, input:keyboard ]

  update(m, msg) = match msg {
    Turned(c)       -> { model: m with { dir: turn(m.dir, c) }, cmds: [] }
    Tick(n)         -> { model: advance(m), cmds: [] }
    FocusChanged(f) -> { model: m with { focused: f }, cmds: [] }
  }
  subs(m) = [ OnKey(Turned), OnTick(120, Tick), OnFocus(FocusChanged) ]
}

The doctrine of edges, which unifies half the invariants above: a discrete journaled edge may enter the Model; a continuous presentation signal never may. Marking an item read, counting impressions, tracking reading progress and lazily loading a list are all expressible — as edges (OnVisible(itemKey, threshold, C)), not as geometry. A scroll position, as a readable value, does not exist.

#The output membrane

Everything the host paints passes one membrane: a sanitizer (text as text, an unknown node rejected, out-of-range values clamped), pinned text metrics, an accessibility layer that is annotated and inferred, picking that returns keys, assets resolved from allowlisted keys, and localization through a host-held catalogue.

There is no route from a script to raw markup, raw bytes, or a raw URL. Not because they are filtered — because they cannot be named.

#Accessibility: you annotate, and the kind infers on top

A scene of pixels is, by default, invisible to a screen reader. Two mechanisms answer that, and they compose — the second is an addition to the first, never a replacement for it.

The annotation is in the language. Every ui primitive carries an optional a11y: record{ role, label, desc } — token-localized, sanitizer-checked. A CANVAS scene{} carries a describe:, its textual alternative. Both are ordinary bounded props (strings and tokens): no new sort, no new grammar.

fluxdef overlayOf(m) = scene {
  when volume > sma(volume, 20) * 2 :
    dot { at: (bar.i, high), r: 4, fill: token.spike,
          a11y: { role: token.roleMark, label: token.spikeLabel, desc: token.spikeDesc } }
}

And the kind infers, for free. Beyond the manual a11y:/describe:, the dimensional kind of a plot or a mark auto-derives an accessible name, a range and a sonification — a sixth inferred output, standing beside the overlay, the pane, the scale, the reference lines and the colour that presentation inference already derives from the kind. plot rsi(close, 14) has kind osc(0,100), and that alone is enough for the host to announce "RSI oscillator, 0 to 100, currently 72, above the 70 guide", and to offer a sonification (pitch is the value, pan is time). No author effort at all — exactly as the pane and the scale cost none.

Note what the inference reads: the kind, not the geometry. The descriptor lives host-side, outside the oracle, and is firewall-safe — it reads the scene and never writes back into it.

Four extensions complete the contract, every one a host prop rather than a language mechanism: live regions (live: Polite | Assertive; a toast is Polite by default), widget states (a11y widens from {role, label, desc} to expanded / checked / selected / disabled / pressed, derived from the Model like any other prop), relations (controls:, describedby:, activedescendant:, addressed by node key — the keyed vnodes are the identity), and intra-pane focus (a roving tabindex inside a composite widget, plus the contractual focus trap of a modal surface). Tab order stays centralized in the one component that touches the DOM, which is also the one place prefers-reduced-motion is applied.

#See also

↑ contents

net — the network as a stream

Post-v1. The network pillar is sealed in design; its capability face (net:fetch, net:stream) is part of the APP-plane contract.

A network connection is a stream on the arrival axis. That is not a metaphor — it is the same statement the language already makes about a chart's x axis, applied to a different clock. And it is what lets one word, stream, cover request/response, server push, polling, a stream-of-streams, and pagination: they differ only in their arrival regime.

The script never opens a socket. It describes a connection; the host — the only holder of the file descriptor, the TLS session and the token — opens it, and delivers what arrives as journaled messages.

#The placement theorem

Where may a network stream live? The answer is forced, not chosen:

Arrival is non-deterministic with respect to the world — the network arrives when it arrives. So it lives entirely in the APP plane, where non-determinism enters as a journaled message and contaminates nothing. Reading the network from analysis is [ErrFirewall].

Promotion into analysis happens by exactly one seam: a causal scan that folds arrivals into closed bars, followed by toSource, which the host ingests append-only — never revising a closed bar. Only then does the ordinary resample operator apply.

A pane can therefore read an exchange feed, a chat gateway, an RSS river. An indicator cannot — it only ever sees the causal series that toSource produced. The firewall is not weakened by the network; the network is routed around it.

#The five verbs

A connection is described by a spec and consumed through the two frozen doors of the APP plane — commands out, subscriptions in. No verb ever carries a file descriptor, a token, or a forged URL: the url is an allowlisted string key the host resolves, at a consented domain.

Verb Kind What it is
request(spec, On) Cmd one-shot request/response; the result re-enters as a message through the constructor it carries
subscribe(spec, On) Sub an inbound push stream (server-sent events, a socket subscription, a polled feed)
connect(spec, On) Sub a persistent bidirectional channel; the companion sink(connKey).send(v) emits an outbound command
datagrams(spec, On) Sub unordered, unreliable datagrams
paginate(spec, On, next, maxPages) Sub unrolls a bounded loop, yielding a stream of pages

A spec is a NetSpec record. You rarely build one field by field: a preset returns one, and with overrides the fields you care about.

fluxvariant Msg { Got(f: Recv) | Ping }

app feed {
  capabilities: [ net:stream ]

  init(p)        = { last: na, missed: 0 }
  update(m, msg) = match msg {
                     Got(f) -> match f {
                                 Data(t)    -> { model: m with { last: t.price },        cmds: [] }
                                 Dropped(n) -> { model: m with { missed: m.missed + n }, cmds: [] }
                                 _          -> { model: m,                               cmds: [] }
                               }
                     Ping   -> { model: m, cmds: [ sink("exchange.ws").send(Ping) ] }
                   }
  view(m)        = text("last {fmt.price(m.last)} · missed {m.missed}")
  subs(m)        = [ connect(ws("exchange.ws") with { codec: Json, schema: Trade }, Got) ]
}

Note what the _ arm is doing: the envelope has seven arms, and match is exhaustive, so the compiler will not let you forget that a socket can drop, lag, or be revoked. Note also Dropped(n) — the model counts what it never saw.

#The arrival envelope

Everything that arrives is wrapped in one variant, declared once, and eliminated by match. It is shown here in spec notation — the <κ> is the metalanguage for "the schema you declared", not surface syntax: a v1 record is monomorphic, and the host supplies Recv already specialised to your schema.

variant Recv<κ> {
  Data(κ)                                        // a decoded unit — κ is the schema you declared
  | DecodeError(field: string, reason: string)   // a broken required field — never a silent `na`
  | Dropped(count: num)                          // backpressure evicted this many
  | Lagged                                       // the consumer fell behind
  | NetErr(class: NetErrClass, reason: string)   // a TYPED failure — branch on the class
  | SchemaMismatch(expected: string, got: string)// the provider's version left the accepted window
  | Revoked(capRef: string)                      // the capability was revoked mid-session
}

variant NetErrClass {
  RateLimited(retryAfter: duration | na)         // pace — never hammer
  | Unauthorized                                  // re-authenticate; do not blind-retry
  | Http(status: num) | Timeout | Dns | Tls | Cors | Refused | Closed(code: num | na)
}                                                 // the WS close code drives resume vs re-identify

Why the error class is a variant and not a string. A reconnection policy that branches on the wording of a host error message is not portable, not testable, and — because the wording is not byte-stable across engines — not replayable. The class is host-authoritative and byte-identical, so RateLimited(retryAfter) → pace, Unauthorized → re-auth, Http(404) → give up, Timeout → retry, is a total function you can write once and trust.

A one-shot request never delivers Dropped or Lagged (there is no backpressure on a single response). A stream can deliver all seven arms — and match makes you handle them.

#The lifecycle is declarative

A subscription that is present means "this connection is wanted"; removing it means "close it". The runtime diffs subscriptions by their connKey, exactly as it diffs everything else.

Each field of a spec carries a class:

Class Fields Effect of a change
Hot backpressure, heartbeat, timeout, subscription filters applied without reopening the connection
Reopen url, protocol, auth, codec, framing reconnects

Changing the connKey itself is an explicit reconnection. A send on a key whose subscription is gone is a no-op that surfaces as a message — never a crash, never a write to a closed socket.

#Codecs

A codec is a projection of a kind — not a parser. You declare the shape you expect, and the host decodes into it and kind-checks at the boundary:

fluxrecord Trade { price: price(BTC, USD) ; qty: volume ; ts: time }

trades = subscribe(ws("exchange.ws") with { codec: Json, schema: Trade }, Got)

codec and schema are two fields, not one. The codec says how the bytes are shaped; the schema says what kind they decode into. Leave schema off and it is inferred from the codec — but naming it is what lets the boundary check hold.

This is what makes the ban on regular expressions and ad-hoc parsing satisfiable rather than merely restrictive: you never parse a payload in the script, because the payload arrives already typed. A required field that is broken gives you DecodeError(field, reason)never a silent na that poisons a computation three hops later.

The decoder is incremental and arena-backed: it fills a bounded buffer as bytes arrive, zero-copy where the layout permits, with no allocation in the steady state. Framing (how a byte stream is cut into messages) is a separate, composable layer, so a codec can be reused across transports. The catalogue is closed — extensible only as a vetted list, never as a runtime grammar. In v1 it is Json, XmlFeed (which is what the RSS/Atom preset decodes with), Utf8 and Raw, plus the compact binaries Cbor and MsgPack, the tabular Csv and Tsv, and Url. Post-v1. Md follows the text pillar, with the protocols that carry it.

Each codec carries an accepted version window. A provider that drifts outside it yields SchemaMismatch(expected, got) at connect, before a single datum is delivered — an "update required", rather than a corrupted model.

#Backpressure is declared, never implicit

A fast producer and a slow consumer is not an edge case; it is Tuesday. So the policy is part of the spec, and every option is total:

BackPressure is a closed variant of five arms, and it is the back: field of the spec:

Policy Behaviour
Latest(n) a bounded ring of the n most recent arrivals — the default for a price feed
DropOldest(n) a bounded queue of n; when it is full, evict the oldest
DropNewest(n) a bounded queue of n; when it is full, refuse the newcomer
Sample(clk) keep-last at each tick of a bar clock — coalescing, for a feed you only sample
Block true backpressure, toward the host: the host stops reading the socket

A hot stream that declares no back takes Latest. There is no unbounded queue, so there is no way to write the classic memory leak where an app quietly buffers a firehose until it dies. And because eviction is countedDropped(n) is an arm of the envelope — the model always knows what it did not see.

The one that is easy to get wrong: Block. A fold from ticks into OHLCV bars must see every tick, or the bar it builds is not the bar that happened. Sample would silently lose ticks between clock edges, and Latest(n) would lose them under a burst. So the bars(tf) fold that feeds the seam into analysis declares back: Block — and the cost of that choice is explicit and local: the host stops reading the socket, rather than the script quietly aggregating a lie.

Rate-shaping is a different axis and lives elsewhere: throttle / debounce / sample / dedup / merge are combinators of the stream algebra, applied to a stream you already have; and pace on the spec token-buckets the pipeline's own outbound requests. Backpressure is what happens when arrivals outrun the consumer — not how fast you choose to ask.

Pagination follows the same discipline: a bounded loop with a declared maximum page count, or an incremental pull driven by the model — never a free loop over an unknown number of pages.

#Asynchrony without callbacks

There is no await, no promise, no callback in the language. A pipeline is described, the host drives it, and every result re-enters as a message through the constructor the command carried. That is the whole story — and it is the reason an application's entire behaviour is reconstructible from its journal.

The cost is honest and worth stating: a long asynchronous chain becomes several message constructors and several arms of update, rather than three lines of await. What you buy is that every intermediate step is in the journal — so time travel, replay and server-side verification all work, which they cannot if the intermediates are hidden inside a task.

#Protocols

The protocol catalogue decomposes into five orthogonal axes (transport, framing, encoding, session, delivery), which is why one spec type covers everything rather than one API per protocol. A preset is a plain function returning a NetSpec with the sane defaults for its protocol already set — a reconnection backoff, a heartbeat, a backpressure ring — and with overrides whatever you need. That collapses the common case to one line:

fluxquotes = request(rest("api.example.com") with { codec: Json, schema: Quote }, GotQuote)
events = subscribe(sse("events.example.com") with { codec: Json, schema: Event }, GotEvent)
ticks  = connect(ws("stream.example.com") with { codec: Cbor, schema: Tick, back: Latest(256) }, GotTick)
posts  = subscribe(rss("blog.example.com/feed", 300s), GotPost)

The presets are rest · ws · sse · rss · wt. rss takes its poll interval as an argument, because a poll without a declared cadence is not a poll; the others read theirs from the transport.

Browser-possible today: HTTP, server-sent events, WebSocket, WebTransport, and the polling feeds. Deferred to a relay: protocols that need a raw TCP socket — the browser cannot open one, and pretending otherwise would be a lie in the documentation rather than a feature.

#Capabilities, auth and governance

The whole pillar sits behind the net:* family, default-deny:

#The seam into analysis

flux// APP plane: every tick (`back: Block`), folded into closed bars, handed to the host
feed = connect(ws("exchange.ws") with { codec: Cbor, schema: Trade, back: Block }, Got)
src  = feed.bars(tf("1m")).toSource("BTC-ext")   // the stream is the receiver — UFCS, no pipe operator

// ANALYSIS plane: it is now an ordinary, causal series
plot ema(series("BTC-ext").close, 20) @ tf("1h")

The host ingests append-only: a closed bar is never revised. So a series fed by an exchange socket carries exactly the same no-repaint guarantee as one fed by the first-party pipeline — not because the network is trusted, but because the seam refuses to let it rewrite history.

#See also

↑ contents

Host services — the resource-handle doctrine

Post-v1. Every family on this page is sealed in design; the rollout follows the v1 language. The status is stated once, here, and not repeated section by section.

An application that may not open a file, read the clipboard, notify a user, sign them in, take a payment, resize an image or load a typeface is a demo, not a product. This page is the effectful layer that closes that gap: user files, clipboard, notifications and scheduled wake, offline, authentication against your own backend, payments, media operations and sensors, print and PDF, custom fonts, and running another application inside yours.

That is a long list of things a sandboxed script is being allowed to do, and exactly one security argument runs underneath all of it. That argument is the resource-handle doctrine, and it comes first because every family after it is a corollary rather than a fresh story to audit.

A note on the samples. A line marked ✗ is a fragment. Flux has no expression-statements, so such a line illustrates a rule; it is not a program. Every positive sample is a program that parses.

#The doctrine

Normative, cross-cutting. A script never holds a byte-carrying resource. It holds an opaque, capability-scoped, session-scoped handle key — a string with no structure the script can exploit — and the host, the only holder of the bytes, the socket, the token and the pixel buffer, resolves it. Every transformation is a named host operation on a handle, drawn from a closed catalogue per family: Cmd Op(handleIn, params, C) returns a msg carrying a new handle or metadata — never content.

The resource-handle doctrine
Figure — the command and the message cross the membrane; the resource never does. Every family on this page is this picture with different nouns.

#What the script may hold

The script holds The host holds
a handle key (string, opaque, session-scoped) the bytes of a file, an archive, a photo
metadata: name, mime, size, w, h the socket, its TLS session, its file descriptor
a verdict: Paid, Declined, Ok(rev), Denied the auth token, the cookie, the refresh loop
a request — inert data in cmds the pixel buffer, the decoded image, the glyph raster

The boundary with pure code is drawn in exactly one place, and it is drawn generously: a declared bounded bufferbuf(N), a vec of bytes over the bits.* substrate — is in-script and pure, so you can write a binary codec, a checksum over data you computed, a bit-packed encoding. What is not in-script is a user file or a platform asset: those are handles plus host ops. The distinction is not "bytes are dangerous"; it is "bytes you did not create do not belong to you".

#Why one doctrine instead of one per family

Each family could have had its own security story: a file API that sanitizes paths, an image API that validates dimensions, an auth API that scopes tokens, a font API that vets a downloaded face. That is one audit per family, one chance to be wrong per family — and one more place, every time the catalogue grows, for a feature to widen a hole nobody is watching.

Why this rule exists. Under the doctrine, the argument is made once. A script cannot exfiltrate what it never held; it cannot forge a handle, because a handle is resolved against a host-side table keyed by the grant; it cannot re-delegate one, because there is no channel that carries authority. A reviewer reading a new family needs to check one thing — does it hand the script the resource, or a key to it? — and if the answer is "a key", the family inherits the whole security argument for free. That is the property that lets the catalogue grow without the attack surface growing with it.

Three corollaries follow immediately, and they are what you feel while programming:

fluxbytes(f.handle)                  // ✗ no such verb — nothing resolves a handle to content
f.handle.pixels                  // ✗ no such field — pixels are host-side, always

Open decision. Handle lifetime across suspend/resume is left open by the plan; session-scoped with an explicit re-pick on resume is the recommendation, not yet a ruling.

#The mould

Every row of the catalogue is the same shape, which is why it is learnable in one sitting rather than family by family:

  1. Intent goes out as an inert Cmd, under a default-deny namespace:verb capability. The command is data — a handle key, a name, a template id, a quantity. It carries no resource.
  2. The outcome comes back as a journaled msg, through the completion constructor the command carried. There is no callback, no promise, no await.
  3. An epoch token absorbs staleness. A command carries an app-supplied scalar; the host echoes it verbatim; a result whose epoch no longer matches is dropped by the arm that receives it.
  4. Revocation rides the membrane. A grant dropped mid-session writes a CapRevoked bound into the journal; a command with a completion constructor is answered [ErrCapRevoked] through it, and a fire-and-forget command is dropped and audited.

A complete application, end to end — pick an image, ask the host what it is, save a copy:

fluxapp thumbnailer {
  capabilities: [ file:pick, file:save, image:ops ]

  init(p)        = { src: na, out: na, w: 0, h: 0 }
  update(m, msg) = match msg {
                     Choose    -> { model: m, cmds: [ FilePick(["image/png"], 4000000, Picked) ] }
                     Picked(f) -> { model: m with { src: f.handle },
                                    cmds:  [ ImageOp(f.handle, Meta, Info) ] }
                     Info(d)   -> { model: m with { w: d.w, h: d.h }, cmds: [] }
                     Save      -> { model: m, cmds: [ FileSave(m.src, "copy.png", Saved) ] }
                     Saved(ok) -> { model: m with { out: ok }, cmds: [] }
                     Cancelled -> { model: m, cmds: [] }
                   }
  view(m)        = row { button("choose…", Choose) ; text("{m.w}×{m.h}") ; button("save", Save) }
  subs(m)        = []
}

m.src is a string. It is the whole representation of a two-megabyte image inside this program. Notice what is absent: no buffer, no decode, no try, no cleanup — and no way for a compromised dependency to read the user's picture, because nothing in scope can.

fluxcmds: [ Notify("hello", args, Tapped) ]   // ✗ [ErrCapDenied] — notify:send is not in capabilities:

#The capability catalogue

Every row is default-deny, host-attenuated, and graded by trust. This table is the map; the sections that follow give each family its grant, its attenuation, and its honest limit.

Capability Grants Host attenuation
file:pick / file:save / file:drop native picker, save-as and download, operating-system drops mime allowlist, size caps, quota; handles session-scoped, never bytes
clip:read / clip:write the clipboard (text in v1) gesture-gated; a read is prompted; pasted content is data, sanitized at render
notify:send Notify, SetBadge, ClearBadge templated content only, rate-limited, consent per the platform permission model
schedule:wake ScheduleWake host-fired; a closed app is relaunched and its payload is the first journaled message
net:offline cache policy per grant, an offline command queue, OnConnectivity replayed under an idempotency key; bounded queue; no multi-writer merge
auth:passkey / auth:session the WebAuthn ceremony; sessions on your own backend ceremonies and forms are host-vetted; the token is host-held; the app sees a handle
pay:checkout Pay, Sub OnEntitlement provider checkout runs host-side; the app never sees the instrument; sellers are vendor-verified
image:ops / capture:photo / capture:qr named ops on handles; camera capture; QR decode closed catalogue, pixels never in-script; consent per capture
geo:read / motion:edges one-shot and watched position; motion edges coarse by default; discrete edges only ([HeldFromEdges])
share:generic Share(record{ text?, urlRef?, fileHandle? }) the platform share sheet: visible and gesture-gated before send
doc:print Print, ExportPdf, ExportImage host-paged render of an already sanitized tree; output is a file handle
asset:font vetted font assets, per-app pinned metrics ([TextMetric]); a declared fallback; never silent reflow
ui:embed appView(appId, params?) child realm, journal and grants — isolated, never inherited
display:awake screen wake-lock visible-only, revocable

#Files and user data — file:*

Editors open and save documents; forums attach files; dashboards import and export. Three verbs cover it.

Verb Kind Delivers
FilePick(accept, maxBytes, C) Cmd the native picker → record{ handle, name, mime, size }, or Cancelled
FileSave(handle, suggestedName, C) Cmd save-as / download; the content is a handle, or app-serialized text under quota
a drop on the pane Sub-delivered msg the same record{ handle, name, mime, size }

accept is a closed allowlist of mime types or extensions, capped by the grant — not a pattern the script composes. The drop zone is the pane's own surface and nothing beyond it ([SurfaceConfine]): an application produces pixels, and accepts drops, only where it holds the lease.

Host ops on a file handle form a closed catalogue — a SHA-256 hash, archive pack and unpack (size-capped), and the image ops of the media family. Text files decode only through declared codecs (CSV, Markdown, JSON): the payload arrives already typed, which is what makes the absence of a regular-expression engine a livable rule rather than a hardship. See text.

The transport of a file — a resumable upload, a ranged download, progress — is not here. It is net, through Sub OnTransfer(reqKey, C). This capability supplies the handle; the network pillar supplies the pipe. The split is deliberate: it keeps one story about backpressure, retries and idempotency instead of two.

The honest limit. A handle does not survive the session. An application that wants "reopen the last document" persists its own state and re-derives, or asks the user to pick again. There is no retained operating-system handle, because a retained handle is ambient authority with a nice name.

#The clipboard — clip:*

Both are gesture-gated: they run inside a user action, and a read is prompted. Nothing is ambient — there is no clipboard event without the capability, and there is no format sniffing in the script.

Why a paste is not a hazard. Pasted content arrives as data, and data is sanitized where it is rendered, like every other string: the view is a tree of vetted primitives, not markup. A paste into a rich-text editor routes through the editing protocol of text, which is an operation on a document model, not an injection of bytes into a DOM. The clipboard is therefore an ordinary message source, and the usual message discipline is the whole of its defence.

#Notifications, badges and scheduled wake — notify:*, schedule:*

This is the retention loop of anything social — someone replied, a mention, a reminder — and the delivery channel that a purely local alert does not have.

Verb Kind What it does
Notify(template, args, clickMsg) Cmd renders a platform notification from a template; a tap delivers clickMsg
SetBadge(n) / ClearBadge Cmd the application-icon badge
ScheduleWake(at, payload, C) Cmd the host fires at at; if the app is closed it is relaunched
toast(…) ui in-app chrome, with an aria-live contract — a view primitive, not this capability

Content is templated, never a free string handed to the platform layer. The template id and its arguments are checked against a catalogue declared by the application; the host renders it.

Why templates and not strings. A free string crossing into an operating-system surface is the one channel an application could use to say something the platform will attribute to us — a fake system prompt, a fake security warning, a phishing line rendered in the platform's own chrome. Templates make that inexpressible while leaving the legitimate case (an argument substituted into a phrase you wrote and we vetted) entirely open. The same discipline governs share:generic, with one relaxation, and for a stated reason: the share sheet is visible and gesture-gated, so the user reads and can edit the payload before it leaves.

#The launch-message clause

A wake fires while the app is open: an ordinary message. A wake fires while the app is closed: the host relaunches the app — and then what?

Normative delivery rule. A host-initiated (re)launch — a notification tap, a scheduled wake, a deep link — delivers its payload as the first journaled message(s) of the new session, through the declared constructor, ordered before any other subscription delivery.

This is worth stating precisely because it looks like it might be a third exception to "update is the only producer of a Model", alongside time travel and migration. It is not.

What the clause actually fixes is ordering. Without it, a launch payload could interleave with the first tick or the first connectivity edge differently on two machines, and the re-fold would diverge. Pinning the payload to rank zero makes cold-start deterministic.

fluxapp reminders {
  capabilities: [ notify:send, schedule:wake ]

  init(p)        = { queued: 0, resumed: na }
  update(m, msg) = match msg {
                     Arm(t)        -> { model: m with { queued: m.queued + 1 },
                                        cmds:  [ ScheduleWake(t, "daily-review", Woke) ] }
                     Woke(payload) -> { model: m with { resumed: payload },
                                        cmds:  [ Notify("review-due", payload, Tapped) ] }
                     Tapped(hit)   -> { model: m with { resumed: hit }, cmds: [] }
                   }
  view(m)        = col { text("queued: {m.queued}") }
  subs(m)        = []
}

The honest limit. There is no background continuous execution. A wake is a relaunch plus a message, not a process that was running while you were not looking. Staying alive while hidden is a different, narrower capability (display:keepalive). Anything else would be a promise the browser does not let us keep.

Post-v1. Server-originated push — the app closed, across devices — is the fan-out half of this family and lives in server (push:send). This section is the local half.

#Offline, cache and connectivity — net:offline

The mechanism is specified in net; its capability face belongs here, because it is what an application actually asks for.

Piece Behaviour
Sub OnConnectivity(C) online / offline / metered, as discrete journaled edges — never a continuous signal
a per-grant cache policy responses served from cache are data like any other; freshness is surfaced
a bounded offline command queue a command emitted offline is queued host-side and replayed on reconnect under an idempotency key

The queue has a declared cap. Overflow is a message to the application, not a silent swelling — the same discipline as every other bounded structure. Completion messages arrive late, and the epoch token absorbs the ones that no longer matter.

A named non-goal. Offline multi-writer merge (conflict-free replicated data types) is not in scope. The queue is single-user, replayed in order. Two people editing the same document offline and reconciling on reconnect is a different product with a different core, and pretending a queue solves it would be a lie told in an API.

#Identity for your own backend — auth:*

Not every application signs in through a known provider. Many have accounts on a backend their author runs. That is what this family is for.

Verb Kind What happens
AuthPasskey(action, rpRef, C) Cmd the host performs the WebAuthn ceremony; the script receives an opaque session handle and a verdict
AuthLogin(formRef, C) Cmd credentials are collected in a host-vetted form surface and exchanged against your declared endpoint
Sub OnSession(C) Sub the session lifecycle, as messages
Cmd Logout Cmd ends it
fluxvariant SessionEvent { Established(h: string) | Refreshed(h: string) | Expired | LoggedOut }
fluxapp forum {
  capabilities: [ auth:passkey, auth:session ]

  init(p)        = { session: na }
  update(m, msg) = match msg {
                     SignIn     -> { model: m, cmds: [ AuthPasskey(Login, "forum.example", Signed) ] }
                     Signed(h)  -> { model: m with { session: h }, cmds: [] }
                     SignOut    -> { model: m with { session: na }, cmds: [ Logout ] }
                     Session(e) -> match e {
                                     Established(h) -> { model: m with { session: h }, cmds: [] }
                                     Refreshed(h)   -> { model: m with { session: h }, cmds: [] }
                                     Expired        -> { model: m with { session: na }, cmds: [] }
                                     LoggedOut      -> { model: m with { session: na }, cmds: [] }
                                   }
                   }
  view(m)        = col { when is_na(m.session): button("sign in", SignIn) }
  subs(m)        = [ OnSession(Session) ]
}

Three things are absent from that program, and their absence is the design.

The token. It is host-held. m.session is an opaque handle; the host attaches the credential to outbound requests under the grant. Refresh is automatic, host-side, and surfaces only as Refreshed(h). A leaked script cannot leak a credential it never had — the exact argument net makes about the socket, generalized.

The password. Sensitive input is collected in a host-vetted form surface and never transits script memory. This is the same precedent as a wallet picker: the surface that takes the secret is not one the application drew.

The key material. For a passkey there is no key script-side, and none host-side either — it lives in the platform authenticator. The ceremony is the host's; the script gets a verdict.

fluxm with { token: e.token }   // ✗ no such field — a session event carries a handle, not a credential

Identity exposed to the application stays pairwise-opaque: an application learns a stable identifier for itself, not one that correlates a user across applications. The exception is definitional and unavoidable — if the account is on the application's own backend, the application is the identity provider.

#Payments — pay:*

Verb Kind Delivers
Pay(sku, qty, C) Cmd host-mediated checkout in the provider's own surface → a verdict
Sub OnEntitlement(C) Sub server-verified entitlements and subscriptions
fluxvariant PayVerdict { Paid(receiptRef: string) | Declined | Cancelled | Pending }

def afterPay(m, v) = match v {
  Paid(r)   -> m with { ui: m.ui with { pending: 0 }, doc: m.doc with { receipt: r } }
  Declined  -> m with { ui: m.ui with { pending: 0 } }
  Cancelled -> m with { ui: m.ui with { pending: 0 } }
  Pending   -> m
}

The script never sees the instrument. Not a card number, not a token, not a redirect it could tamper with: the checkout runs in the provider's host-side surface, and the application receives a verdict and a receipt reference — another opaque handle, which the server validates (see server).

Amounts are decimal money[Q] — exact fixed-point, carrying the quote currency as an inferred tag rather than as surface syntax. The reason is not fastidiousness: binary floating point cannot represent 0.10, and a price that is off by an ulp is a bug you discover in an accounting reconciliation months later. See asset & currency and the decimal.* namespace in compute.

Pending is a first-class arm, not an error. Some payment methods settle asynchronously; the verdict says so, and the entitlement arrives later through OnEntitlement. An application that grants access on Paid alone and never listens for the entitlement has a bug the type system cannot catch — but match at least forces you to look at Pending.

Open decision. The provider set for v1 is open. The capability is provider-agnostic by design; which providers ship first is not settled.

#Media, capture and sensors — image:*, capture:*, geo:*, motion:*

#Image operations are a closed catalogue

fluxvariant ImgOp { Resize(w: num, h: num, fit: fit) | Crop(area: rect) | Rotate(quarter: num)
              | Filter(preset: string) | Meta }

Cmd ImageOp(handle, op, C) takes a handle and one named operation, and returns a new handle or metadata (dimensions, mime). That is the entire surface.

Why the catalogue is closed. The alternative is pixels in the script — an array the program reads and writes, and therefore a convolution, an FFT, a filter kernel you wrote yourself. That is a fine thing for a language to have and a bad thing for this one: it puts an unbounded, data-dependent loop in the middle of a total language, and it hands a sandboxed script the contents of a user's photograph. A closed catalogue of named ops is the shape that is both sandbox-compatible and honest — and general 2-D signal processing in-script is a named non-goal, not an omission.

#Capture

Cmd CapturePhoto(C) yields an asset handle. Cmd ScanQr(C) yields msg(string) — a decoded payload, delivered as data. Both are consent-gated per capture. Neither is the audio/video call path, which is a network profile and lives in net.

Open decision. Whether capture:* on a desktop browser with no camera degrades to a declared file-pick fallback is left open by the plan.

#Sensors deliver edges, never samples

fluxapp tracker {
  capabilities: [ geo:read ]

  init(p)        = { fixes: 0, last: na }
  update(m, msg) = match msg {
                     Moved(pos) -> { model: m with { fixes: m.fixes + 1, last: pos }, cmds: [] }
                   }
  view(m)        = col { text("fixes: {m.fixes}") }
  subs(m)        = [ OnGeo(60000, Moved) ]      // at most one fix a minute
}

Cmd GeoOnce(accuracy, C) reads a position once; Sub OnGeo(minInterval, C) watches. Consent is coarse by default — a granted position is a neighbourhood unless the user says otherwise.

Orientation and motion expose discrete-edge subscriptions only: a threshold crossing, a shake gesture. There is no continuous heading, no per-frame accelerometer sample.

Why edges and not samples. This is [HeldFromEdges], and it is a determinism rule before it is a privacy rule. A Model is a fold over journaled messages; the fold must reproduce bit-for-bit on replay, on another device, at another frame rate. A continuous sensor sample is none of those things — its cadence depends on the hardware and the load, so two replays of the same session would see different message counts and diverge. A discrete edge ("the threshold was crossed", "the device was shaken") is rank-deterministic: it is a message like any other. A held sample that reaches a Model field a verdict reads is [ErrFirewall], checked at compile time — the same rule that keeps pointer position and hover out of a game's score.

#Share and wake-lock

Cmd Share(record{ text?, urlRef?, fileHandle? }) opens the platform share sheet — visible, gesture-gated, editable by the user before it sends, which is exactly why a free text field is admitted here and refused to Notify. display:awake keeps the screen on while the pane is visible, and is revocable.

#Print and document export — doc:print

Verb Output
Cmd Print(viewRef) the host renders the pane's retained tree to paged media
Cmd ExportPdf(viewRef, C) the same render, delivered as a file handle → save or share
Cmd ExportImage(sceneRef, format, C) a scene to PNG (raster) or SVG (sanitizer-emitted), as a file handle

The host owns pagination: page size, headers and footers are template tokens; page-break hints are cosmetic container properties on the presentation stratum, so they can never change what the document is, only where it breaks. There is no new script surface at all here — the tree being printed has already been sanitized for the screen, and printing is a second renderer over the same value. Export lands as a file handle, and the doctrine takes over from there: the script never touches the produced pixels either.

#Typography — asset:font

A font is a host-loaded, vetted asset, keyed and quota'd like any other, which extends the typography token allowlist for that application only. There is no @font-face, and no font URL — those remain inexpressible.

Why a font is a determinism problem. Text metrics feed layout. If two devices resolve a typeface differently — a fallback here, a slightly different face there — the same program produces a different tree of boxes, and the geometry that the oracle checks byte-for-byte stops matching. So each font, at each version, lands with pinned metrics joining the [TextMetric] set: the measurement routine is shared by the interpreter, the compiled module and the server, and a layout cannot reflow differently on two machines. A missing font is not a silent substitution: the declared fallback applies and a diagnostic is raised.

That is the whole of the relaxation. Custom typography was excluded from the display pillar for exactly this reason, and this capability is its sanctioned re-entry — vetted asset, pinned metrics, declared fallback.

#Runtime app composition — ui:embed

appView(appId, params?) -> ui instantiates another application in a child slot: its own WASM realm, its own journal, its own grants.

fluxapp dashboard {
  capabilities: [ ui:embed ]

  init(p)        = { child: "clock-widget" }
  update(m, msg) = match msg { Swap(id) -> { model: m with { child: id }, cmds: [] } }
  view(m)        = col { appView(m.child, { theme: "dark" }) }
  subs(m)        = []
}

Why embedding cannot amplify authority. Nothing is inherited — in either direction. The child does not receive the parent's grants, so a hosted mini-app cannot borrow the dashboard's network access; and the parent does not receive the child's, so an embedder cannot harvest what a user granted to the app it hosts. The two communicate through exactly two declared channels — the params passed at mount, and the capability-gated context bus — and neither carries a Model, a journal, or a capability. Composition is therefore flat with respect to authority: an application's manifest is what it is, whoever hosts it.

The child's lifecycle rides the slot's port — mount, suspend, dispose — the chartView cycle generalized. A crashed child shows the layout manager's error card in its own slot, and the embedder keeps running: fault isolation is per-realm.

The consumers are the obvious ones: a dashboard hosting mini-applications, a course page embedding a live exercise, a marketplace composing an app inside an app. It complements package-level composition — packages compose code, appView composes running programs.

Open decision. The embedding depth cap is open; depth one is the recommendation for v1.

#What remains inexpressible

Nothing on this page relaxes an invariant. Every family adds a row to the capability table and nothing else — no lattice sort, no grammar symbol, no arrow, and no change to the firewall or to totality. And for every tier, trusted or not, these still have no name in the language: eval and code generation, the raw DOM, a raw socket, a raw database client, a token, a cookie, a global store, and the bytes behind a handle.

A trusted tier grants effects. It never loosens causality, no-repaint (a value, once produced for a step, never changes), totality or the firewall — which is why an application that can print, pay and take a photograph is still an application whose entire behaviour is a fold over its journal.

#See also

↑ contents

server — the application, running without a screen

Post-v1. The server plane is sealed in design; its rollout follows the v1 language and is gated on its first consumer. The status is stated once, here, and not repeated section by section.

Everything described so far executes on the client. That is a real position, honestly held — but it leaves a list of needs that keep arriving from different directions and turn out, on inspection, to be one missing design: a leaderboard that can prove a score was earned, a forum that has to store posts somewhere, a webhook that has to land somewhere, a public page a crawler can read, a push notification to a device whose app is closed.

This page is that one design. Its central claim is smaller than it sounds and larger in consequence: a server application is the same application, headless. Not a second language, not a second runtime, not a second mental model — the same app block, without a view.

#The thesis — one plane, the consumers it unifies

Consumer What it needs from this plane
Grader / anti-cheat re-execution run a client's journal again, server-side, and issue a verdict
Shared durable state for third-party applications a place a forum, a board or a room can live
Inbound webhooks a URL a payment provider or a data vendor can call
Remote alert and push delivery reach a device whose application is closed
Receipt and entitlement validation verify a purchase somewhere the buyer cannot edit
Metered-feed billing count what was consumed, where the counter can be trusted
Governance mirror an append-only, hash-chained audit of a fleet
SEO prerender serve a public page that is readable without executing anything
Licensed server-side execution run a module on our infrastructure under its licence
Always-on collaboration sequencer a single authority that orders concurrent edits

Ten needs; one plane. The reason they collapse into one is that all ten are folds — an init, a stream of messages, a total update — and that the one which must also render reaches for a pure view and nothing more. The client already has that machine, and it already has an oracle that proves two engines run it identically. The server plane is that machine, hosted.

Post-v1. A one-to-many media relay is the exception: it is media infrastructure rather than a fold, and it is deferred to last.

#The substrate — a hosted backend, no second vendor

The plane is built on the backend the platform already runs, and introduces no second one:

Piece Role
managed Postgres with row-level security durable storage, with the ownership discipline enforced in the database
an edge function runtime that executes WebAssembly natively the execution host for a headless application
object buckets published sources and compiled modules, exactly as the client publication pipeline uses them
a realtime change feed the transport under Sub OnSharedChange

The deployment unit is not "a build". It is the pinned build closure:

build-hash = source · lockfile (the transitive module-hash closure) · compiler version
           · pinned routines · the canonical memory plan

The rebuild gate — the check that already stands between a source change and a shipped module — becomes the deploy gate. A server never runs bytes that differ from the bytes the client verified. That single sentence is what the rest of this page rests on, and it is the reason the determinism story survives the move off the device.

#Zero language change — an app without a view

A server worker is an app block with init, update and subs, a server capability set, and a server-held journal. No new authoring model. No free-form server code. The Elm Architecture (TEA) that the APP plane describes is the whole of it, minus the member that paints.

fluxapp grader {
  capabilities: [ storage:shared ]

  init(p)        = { graded: 0, rejected: 0 }
  update(m, msg) = match msg {
                     Scored(r)  -> { model: m with { graded: m.graded + 1 },
                                     cmds:  [ SharedPut(r.runId, r.verdict, Written) ] }
                     Written(v) -> match v {
                                     Ok(rev)       -> { model: m, cmds: [] }
                                     Conflict(rev) -> { model: m with { rejected: m.rejected + 1 }, cmds: [] }
                                     Denied        -> { model: m with { rejected: m.rejected + 1 }, cmds: [] }
                                     QuotaExceeded -> { model: m with { rejected: m.rejected + 1 }, cmds: [] }
                                   }
                   }
  subs(m)        = [ OnQueue("runs", Scored) ]
}

That is a complete server. It has no view, and the compiler does not miss it: the five members are optional, and a program that never renders never declares one.

Why this is not a convenience. Because update is pure, total and deterministic, two operational problems that normally need bespoke engineering stop being problems. Horizontal scaling: any instance can serve any message, because there is no hidden state to migrate — the Model is a fold. Crash recovery: an instance that dies is replaced by one that re-folds the journal from the last checkpoint. These are the client's own mechanisms — checkpoint and journal — running on a machine with a different address.

#The one thing the server must decide that the client never did

A pure fold is deterministic given an order. Two edge instances receiving concurrent deliveries do not agree on an order by being pure — purity is not a consensus algorithm, and treating it as one is the classic mistake.

So the ordering authority is the shared-storage substrate, and it is the only one. A revision (rev) is assigned by the backend on write, never elected by an instance. Concurrent subscription deliveries serialize through that assignment, and the journal each instance re-folds is the authoritatively ordered one.

#The server subscription catalogue

Subscription Delivers
OnWebhook(path, C) an inbound HTTP call, decoded at the boundary against the schema the app declared
OnSharedChange(scope, C) a change in a shared collection, as journaled messages
OnJob(spec, C) a scheduled run — the server twin of schedule:wake
OnQueue(name, C) a work item

Ingress obeys the discipline the network pillar already established: a webhook payload is decoded against a declared schema, and a broken required field is a typed message, never a silent na that corrupts a Model three hops later. See net.

#The server command rows

Capability Grants
storage:shared the collection verbs below
mail:send templated transactional email — templated, for the reason host services gives
push:send Web-Push fan-out to registered devices — the delivery half of the client's notify:*
net:fetch egress, under the same per-domain grant model as the client, with a server egress budget
pay:* receipt validation: the client's Paid(receiptRef) hands over a reference, and the server is where it is checked and recorded

Declared, not deployed ad hoc. A server application ships through the same publication pipeline as any other: source → compile → manifest sealed → provenance row. There is no back door where a script arrives on the server by another route, which is what keeps the audit-at-publication story true for server code as well as client code.

#storage:shared — hosted collections with tiered access

Where does a forum live? Per-user storage (storage:own) is the wrong shape — a post is not private. First-party collections are the wrong shape — they are first-party by construction. The missing capability is a shared, durable collection an application can declare, with access control an author can state and a reviewer can read.

#The four tiers

Tier Who may read Who may write The shape it serves
Own the owner the owner per-user documents — the storage:own semantics, hosted
Room(roomKey) members of the room members of the room a session, a board, a collaborative document
PublicRead everyone the owner a published article, a profile, a leaderboard
PublicAppend everyone everyone, under quota and moderation the forum shape: anyone may post, nobody may rewrite

An application declares its collections, its tier per collection, and its indexes at publication. The compiler turns each tier into row-level security policy in the database.

Why a closed set of tiers, and not a rules language. A general rule language sounds more expressive, and it is — including in the ways you did not intend. It is a second program, written in a second language, running in the security path, with no type system, no totality argument, no test harness and no reviewer who is fluent in it. Its failure mode is silent and total: a rule that reads one clause too permissively exposes a table, and nothing about that is visible in the application's source.

Four named tiers have a different failure mode, and that is the whole point. The tier is in the manifest — inspectable before install, alongside the capability list. It compiles to a policy the database enforces below the application, so a bug in the application cannot bypass it. And because the set is closed, the mapping tier → policy is written once and verified once, rather than re-derived per app by whoever wrote the rules that day.

This is not a smaller feature dressed up as a safety property. It is the safety property: the reason we can say "an application cannot read another user's row" is that no application can express the rule that would let it.

And, in the same spirit: never raw SQL. Not restricted SQL, not sanitized SQL — no SQL surface at all. There is no string that becomes a query, and therefore no injection.

#The closed verb set

Verb Kind Delivers through C
SharedGet(key, C) Cmd the value, or NotFound
SharedQuery(index, range, limit, C) Cmd a bounded row set over an index declared at publication
SharedPut(key, value, C) Cmd a write verdict
SharedDel(key, C) Cmd a write verdict
Sub OnSharedChange(scope, C) Sub changes in scope, as journaled messages

Every one of them carries a completion constructor — including the writes:

fluxvariant WriteVerdict { Ok(rev: num) | QuotaExceeded | Denied | Conflict(rev: num) }

Why a durable write is never fire-and-forget. A command that changes durable, shared state and returns nothing gives an application exactly two options, both wrong: assume it worked, or poll. The verdict closes that: Ok(rev) gives you the new revision, Denied tells you the tier refused you, QuotaExceeded tells you the truth about the quota, and Conflict(rev) tells you somebody else got there first.

Conflict(rev) is the optimistic-concurrency arm. A write carries the revision it was based on; if the stored revision moved, the write does not land and the application is handed the revision that won. It re-reads, re-derives, and decides. What never happens is a silent overwrite — the failure mode where two users both "succeeded" and one of them lost their work without anyone being told.

#Values are typed at the boundary

A stored value is decoded against the schema the application declared, with the same machinery the APP plane already uses for persistence: a tolerant reader, SchemaMismatch when a provider drifts outside the accepted version window, and a total migrate when the shape moves. Amounts may be decimal — exact fixed-point, which is what money is. Quotas apply per application and per user.

PublicAppend collections additionally carry host-side rate limits, report hooks, and owner or administrator deletion. That is platform chrome, not script surface: an application cannot grant itself moderation powers by declaring them, and an application cannot avoid moderation by not declaring them.

Open decision. The precise moderation surface for PublicAppend — which report and delete hooks are platform-level and which are application-level — is left open by the plan.

#The bounded shared query

fluxPAGE = 50

app board {
  capabilities: [ storage:shared ]

  init(p)        = { rows: vec.fill(PAGE, na) }
  update(m, msg) = match msg {
                     Load       -> { model: m, cmds: [ SharedQuery("byTime", (0..PAGE), PAGE, Rows) ] }
                     Rows(rs)   -> { model: m with { rows: rs }, cmds: [] }
                     Changed(e) -> { model: m, cmds: [ SharedQuery("byTime", (0..PAGE), PAGE, Rows) ] }
                   }
  view(m)        = col { for p in m.rows -> text(p.body) }
  subs(m)        = [ OnSharedChange("board", Changed) ]
}

The Model's row buffer is a fixed vec of capacity PAGE, and a short page leaves na in the tail. There is no compaction step in that view, and none is possible: the language has no filter, and where / mask are length-preserving — they blank an entry, they never remove one (collections). What makes the view correct anyway is that iteration is na-aware: a comprehension emits no child for a hole. So a page of eleven posts in a buffer of fifty renders eleven rows, and the thirty-nine holes produce nothing — without a filter, and without a length that depends on the data.

Three constraints are visible in that one command, and each is load-bearing.

The index is named, and it was declared at publication. You do not query by an arbitrary predicate; you query an index that exists. This is the storage twin of the rule that there is no filter in the language: a data-dependent scan has a data-dependent cost, and a plane that promises bounded cost cannot host one.

The range and the limit are bounded, and the limit is const-folded. A query cannot return "all the posts". It returns a page, into a vec whose capacity the Model declared — which is why the Model stays bounded ([ErrState] if it does not) and why the memory plan stays flat no matter how large the collection grows on the server.

A change is a message. OnSharedChange delivers through the realtime feed, journaled like every other subscription — so a collaborative view re-queries because it received a message, not because it polled. The re-query is explicit in the sample above, and that is deliberate: the plane does not secretly refresh your Model behind your back.

#The first consumer — the grader

The leaderboard grader is the smallest end-to-end proof of the whole plane, and it is where the rollout starts.

A client plays; every input it received is in its journal. It submits the run. The server instantiates the same module the client ran — same build-hash, same pinned closure — and re-folds (init, [msg]). Then it compares.

Two inputs make that re-fold trustworthy, and both already exist for other reasons:

What this changes about replay. Until now, replay has been a developer feature: time travel in the editor, a test harness with no mocks, a bug report that reproduces exactly. The server plane makes it a security mechanism, without adding anything to the language. If a verdict is a pure function of (init, [msg]), and the server can recompute that function on the same bytes, then a claimed score is checkable — and a forged one is a journal that does not fold to the claimed result. The anti-cheat property was not designed; it was inherited from determinism. That is the strongest argument this design makes for purity, and it is the reason the grader ships first: it proves the pinned closure and the headless application, and it needs nothing else.

#The limit that argument has

"A forged run does not fold to the claimed result" is true for a score derived from the seed and from the elapsed time, because the server owns both. It is not true for every score, and the gap is worth stating precisely rather than leaving a reader to find it.

A score fed by a host-pushed outcome re-folds without divergence. Consider a game whose result depends on something the host decided and handed to the application — a kernel result revealed as a message, OnReveal -> Revealed(outcome). That outcome enters the journal as data. It is not re-derived from the seed on the way back — the seed re-derives the messages that came from randomness, and this one did not. So the server's re-fold replays the outcome exactly as written, including a forged one, and reaches the claimed result without a whisper of divergence. The check passes. The claim is still a lie.

The same class covers a pixel. A reading taken from the client's viewport — a distance, an angle, anything derived from pan and zoom — cannot be re-derived by a server that has no viewport. A forged pixel scalar re-folds cleanly for the same reason. Hence the standing rule: a pixel value never feeds a ranked verdict. It is a readout, or it is cosmetic. That is a language-level discipline, not a server one, and it holds whether or not this plane exists.

The server plane is what closes the first one. Because an outcome comes from a host kernel, and this runtime executes WebAssembly natively, the server can re-run that kernel itself and re-derive the outcome rather than trusting the journal's copy of it. The re-fold then has a server-derived outcome, a server-derived seed and a host-authoritative clock, and the divergence argument closes over the whole verdict.

Until it does, an outcome-fed run has exactly two honest destinations, and no third: the host re-derives the outcome, or the run is local-score-only and excluded from the shared leaderboard. It is never accepted on the strength of the client's journal alone.

The verdict is written to the score row with its receipt. Nothing about the game had to be written twice, and no "server-side rules engine" exists to drift out of sync with the client — the two are the same artifact, by construction.

What lands after it. Shared storage behind the first community feature; then the collaboration sequencer and the governance mirror on the same runtime; then push and mail fan-out. The prerender projector can land independently of all of them, because it depends on nothing in storage:shared.

Open decision. Whether an always-on sequencer can tolerate edge cold-start latency, or needs a persistent channel, is decided at its consumer, not here.

#SEO prerender — a pure view projection

An application that renders through WebAssembly presents a crawler with an empty page. For a public course, a documentation site or a forum, that is not a trade-off — it is a disqualification.

The seam that fixes it needs no language change at all, because it is already there:

view( fold( init(p), prerenderMsgs(route) ) )      — pure · total · inside the I7 oracle

fold and view are pure functions of data. The server can therefore run the same WebAssembly module at publication time and project the resulting UiTree into sanitized, semantic HTML.

#The projector is the sanitizer's server twin

UiTree node Projects to
containers (col, row, grid, panel) semantic tags
text, label text nodes
image(assetRef) a resolved <img>
rich text semantic prose
a link a real anchor — crawlable
chartView a static poster image

It is the same closed catalogue the client sanitizer paints, targeting a different backend. An application cannot inject markup into a prerendered page for the same reason it cannot inject markup into a live one: no primitive produces markup, and the node set is closed.

#Fixtures, gating, and freshness

Per-route fixtures. An application declares prerenderMsgs per route — the OnRoute payloads that put the Model into the state that route should show. Each public route yields a static page. Gated content renders its teaser, exactly as the access model already specifies: the crawler sees what a signed-out visitor sees, which is the only honest thing to serve it.

Dynamic shared content. A pure fold cannot read storage:shared — purity is not negotiable even here. So the projector run embeds a host-fetched SharedQuery snapshot in the route's fixture messages, and re-prerender of an affected route is triggered by the collection's write hooks, batched and debounced.

The consequence is stated plainly rather than glossed: crawlability of shared content is eventual, not live. A post is visible to a crawler shortly after it is written, not at the instant it is written.

Never per-request. There is no server compute per hit. A page is produced at publication, and regenerated incrementally when a write invalidates it. That is a deliberate ceiling on the operational surface of the whole plane: the request path stays a static file served from a CDN, and no traffic spike can become a compute bill or a cold-start latency.

Hydration is boot. The emitted page carries the standard application boot; the application starts and repaints from scratch. There is no DOM-state reconciliation step, because a deterministic repaint makes one unnecessary — the machinery other stacks need to reconcile a server-rendered tree with a client-rendered one has nothing to do here.

Open decision. Locale variants — one prerendered page per locale, or a single page with an hreflang set — are open, and interact with the catalogue model in i18n.

Open decision. The freshness policy for dynamic shared content — the acceptable lag, the batching window, and whether a high-churn collection opts out of prerender entirely — is open.

#Trust, tiers and quotas

Post-v1. Server applications are vendor-verified only at first. User-generated server code is deferred behind the same gate as any other capability that can spend our resources on somebody else's behalf. This is a rollout decision, not a language boundary: the code that will eventually run there is the code that runs there now.

Everything else carries over verbatim from the client:

Open decision. The receipt and entitlement schema for pay:* validation is not settled; it is co-designed with the payments family in host services.

#The third leg of determinism

Byte-identity has been a two-party claim: the interpreter and the compiled module agree, and the I7 gate proves it at every compilation. The server plane makes it a three-party claim.

Leg The statement Enforced by
interpreter ≡ WASM the two client engines produce the same bytes the I7 gate, at every compilation
WASM (client) ≡ WASM (server) the server runs the same artifact the pinned build closure = the deploy gate
interpreter ≡ WASM ≡ server a verdict computed on a device and recomputed on the server is the same bytes both of the above, together

The third leg is not free. It holds if and only if two conditions hold, and both are mechanical:

The server executes the same pinned routines. Every place where two implementations could legitimately differ — decimal arithmetic, transcendentals, the seeded integer generator, collation, na-ordering, text metrics — is served by one routine, shared. Not the same algorithm: the same code. A server that reached for its own platform's decimal library, or its own Math.log, would be a third implementation, and a third implementation is a third set of bugs.

The server re-links the exact dependency closure. The build-hash pins the lockfile, which pins the transitive module-hash closure. A server that resolved a dependency to a newer compatible version would be running a different program that happens to type-check — and it would disagree with the client on some input, eventually, in a way nobody could reproduce.

Why this is worth the discipline. Without both conditions, "byte-identical" is a promise between two parties who trust each other — a client and a compiler on the same machine. With them, it is a fact a third party can check: anyone holding the journal and the build-hash can recompute the result and compare. That is the difference between a determinism story you use to debug and one you can put a leaderboard, a receipt or an audit behind.

The honest residual. The floating-point caveat that qualifies determinism on the client — the same-runtime clause for f64 series computed by the engine's core paths — does not dissolve by moving to a server; it applies where it applied before. What the third leg covers is the domain that matters here: the fold and the view, and the decimal amounts inside them, which are exact integer arithmetic through a shared, pinned routine. WebAssembly's floating point is specified bit-for-bit across machines, and both sides run the same closure — so the guarantee is byte-determinism of the artifact, never a claim about the surrounding host engine.

Open decision. Multi-region deployment is safe by the same closure argument — the same bytes run everywhere — but the operational discipline that keeps regions in step is not yet written.

#See also

↑ contents

Compiler and runtime

Flux runs on two engines: a graph interpreter, which serves the editor, the live preview and the debugger, and a compiled WebAssembly module, which serves the run. They are not an approximation of each other. They produce the same bytes, and that equality is checked at every single compilation, on hostile data, before anything ships.

That one invariant is the spine of everything else on this page. It is what lets the engine be swapped underneath a running chart with no visual glitch, what lets an optimizer be aggressive without being trusted, and what lets a server re-execute a client's work and detect a lie.

Together the two engines are one abstract machine — the FVM, the Flux Virtual Machine: a total, sandboxed, deterministic dataflow machine whose instruction set is the kernels and the graph operations, whose memory is the static linear layout of the memory model, and whose arithmetic is pinned to the byte. Neither the interpreter nor the WebAssembly module is the FVM; each is a way of running it, and I7 (below) is the guarantee that the two ways cannot disagree. It is a virtual machine in the exact sense the JVM is — a single semantics with more than one conforming implementation — not a bytecode loop: the "instructions" are a typed graph of kernels, and the two implementations lower it differently but must land on the same bits.

#The pipeline

source
  │  parse            Lezer — incremental, total: an arbitrary input yields one tree or a clean error
  ▼
  │  resolve + inline names and `def` bodies  (the call graph is acyclic, so inlining terminates)
  ▼
typed DAG            kinds inferred bottom-up; presentation derived from the kinds
  │  causality check  every feedback cycle must cross a unit delay
  ▼
  │  optimize         common-subexpression elimination · dead code · constant folding · fusion · kernel selection
  ▼
  │  memory plan      liveness intervals → slots → an exact footprint
  ▼
emit ──┬─→ interpreter closures     (edit · preview · debug · THE ORACLE)
       └─→ WebAssembly (Binaryen)   (run · distribute)

The compilation pipeline
Figure — one graph, two back-ends, one gate between them.

The interpreter pays once, when the graph is built. After that the hot loop is native kernels over pre-allocated columns.

The unit of compilation is (graph, resolved parameters, bar capacity). Parameters are resolved at compile time — changing a knob recompiles and re-gates. That is a deliberate trade: it buys a fully static state layout, an exact min = max memory, and a gate that validates the very bytes and the very instance that will serve. There is no gap between what was verified and what runs.

#Two engines, one contract

Interpreter WebAssembly module
Serves editing, live preview, the dataflow debugger the run, and distribution
Feedback instant — no compile step in the loop compiled and gated, then swapped in
Role in the contract the oracle the candidate

#I6 — a leaf is byte-identical to its kernel

A node that maps to a native kernel produces exactly the bytes that kernel produces, warm-up included. Flux does not impose its own na-until-N convention: a Flux indicator on a clock is the same citizen as a built-in, from the first bar. That is what makes "rewrite the catalogue in Flux" a safe proposition rather than a rewrite of every golden.

#I7 — the interpreter and WASM agree, byte for byte

The gate runs at every compilation and it blocks:

The I7 oracle
Figure — the gate compares the two engines on adversarial data, in batch and live, before a single byte is allowed out.

Why the live path is in the gate too. Batch equality is the easy half. The live path steps one bar at a time through a different code path in the module, and it is exactly where a subtle state bug hides. In the emitted module, one shared body serves both — the batch range and the single-bar advance are the same code, called with different bounds — so live ≡ batch by construction; the gate then checks the one seam that remains (column bases versus the live scratch) rather than trusting it.

#What it buys, concretely

#The browser path, as built

The main thread never imports the compiler. It orchestrates:

  1. A registered script serves immediately, on the interpreter — the proven path, with no compile step between typing and seeing.
  2. The service compiles in a worker, where compilation and the I7 gate are atomic: divergent bytes are never handed back, because they are never handed back at all.
  3. When the gated module exists, the service upgrades silently. By I7 the bytes are identical, so the swap requires no re-render and produces no visible event.

The compiler bundle is fetched lazily, on editor intent — never on the chart path, and excluded from the offline precache. Compiled modules are cached in tiers (attached instances by script and configuration; modules by a canonical key, in a bounded cache), because the expensive part is compilation, not instantiation.

#Determinism: the pinned-routine discipline

Byte-identity does not survive first contact with a standard library. Two engines can disagree about the last bit of a logarithm, the sign of a zero, or the rounding of a half-integer, and every one of those disagreements is enough to break replay. So Flux pins the routines — the same code, on both sides, with no delegation to the platform.

Routine Pinned to Why the obvious choice is wrong
log exp sin cos tan atan atan2 pow a pinned WebAssembly libm, used by both engines two JavaScript engines differ by ≥ 1 unit in the last place
% the pinned library's remainder, linked directly module-to-module the remainder itself is exactly specified — what is pinned is the binding: routing it through the host instead would reshape a produced NaN, and the canonicalization has to happen in one place
round f64.nearestties to even half-up rounding disagrees on every half-integer
min / max the exact na-absorbing selection chain the native instructions propagate NaN and return −0 for min(−0, +0) — both disagree with the language's rule
NaN constants emitted as an integer bit pattern, then reinterpreted a NaN marshalled through a host API has no guaranteed bits
na at rest the canonical quiet NaN 0x7FF8000000000000 WebAssembly leaves a produced NaN's sign and payload undetermined
decimal one shared multi-limb integer routine a floating-point engine has no native 128-bit integer; any emulation would diverge
string, fmt.* pinned Unicode tables and one canonical formatter platform string length is in UTF-16 units; platform number formatting differs in the last digit
calendar a pinned epoch ↔ civil routine, pinned time-zone data two correct implementations still disagree on DST gaps and end-of-month clamping
rand(seed) one pinned counter-based integer generator integer arithmetic is bit-identical for free; floating-point mixing is not
ordering of na in sorts one pinned total order (absent values last, stable by index) a partial comparator leaves the order to the platform's sort
the memory plan a deterministic function of the graph the value oracle is blind to layout, so nothing else would catch a divergence

Why the interpreter does not call the platform. It is written in TypeScript and runs on a JavaScript engine, so Math, Number, String.prototype, Date and Intl are right there. Using them would make the interpreter agree with itself and disagree with the module — and the disagreement would be invisible to a program (nothing observes a NaN payload; nothing observes the last bit of a logarithm) and visible only to a byte-level oracle. The discipline is: the interpreter runs the same pinned routine the module runs.

#The toolchain is pinned too

The lowering to WebAssembly goes through Binaryen, and it must be a deterministic function:

Optimization is mandatory, not optional — an unoptimized module is not a "safer" module, it is a different module, and the whole point is that there is only one.

The emitted bytes are a pure function of (program, toolchain), so the toolchain has a single identity — the compiler version, the Binaryen pin, the identity of the pinned math routines, and the enabled feature set — and every cache key for a compiled artifact carries the whole of it. Without that, bumping an emission rule would keep serving stale bytes that a recompilation no longer produces: a cache that is keyed on less than the toolchain is a silent divergence with a long fuse.

Minification and obfuscation, where used, are deterministic and applied after the gate, on the distribution artifact. The provenance hash and the rebuild gate are defined on the artifact that actually ships.

#The runtime surface

The compiled module exposes the same shape the native engine already uses:

Surface Meaning
make instantiate and reset
advance(bar) the live step
run(n) the batch range
snapshot / restore a copy of the state region — a checkpoint is a memcpy, and resumption is byte-exact

One body serves run and advance, so the live and batch paths cannot drift apart, and a checkpoint is a contiguous copy rather than a bespoke serializer.

#Why WebAssembly

Not for speed. The honest measurement: fusing kernels with glue gains nothing in batch (the boundary is amortized over a long history), SIMD is excluded by byte-identity (a horizontal reduction reassociates floating-point and changes the bits), and scalar f64 compute in a modern JavaScript engine is already within a small factor. Real speed comes from algorithmic work and native kernels, not from the execution language.

WebAssembly is the target for four structural reasons:

  1. One execution artifact from day one. The interpreter and the module must agree bit for bit; freezing that equality at the start is far cheaper than retrofitting it.
  2. Cross-machine determinism. WebAssembly's floating-point semantics are strictly specified — which is what makes server-side re-execution meaningful rather than "same runtime, probably".
  3. Application panes. One execution and distribution format for indicators, representations, drawings, scenes, transitions and application logic.
  4. An opaque, sandboxed distribution artifact. A shared or purchased script ships as WASM, never as source: the intellectual property is protected, and the consumer's trust boundary is a binary plus a sealed manifest.

The honest frontier. WebAssembly computes. It never paints: it produces geometry, a draw-list, and a view tree in linear memory, and the host — JavaScript, or the GPU — does the painting. There is no Web API reachable from the module. Two-dimensional rendering stays vector (sharp at any zoom); three-dimensional rendering goes to the GPU. A software rasterizer inside WebAssembly was considered and rejected: it loses vector sharpness and is slower than the GPU.

eval and dynamic code generation remain forbidden. WebAssembly is not "eval with extra steps" — it is a sandbox with linear memory, no DOM, and host access only through vetted imports, admitted by its own dedicated policy token.

#Budgets — counted at compile time, never timed at run time

A budget that is enforced by a stopwatch is not a budget: the same script would be accepted on one machine and killed on another, and replay would die of it. Every ceiling in Flux is therefore a counter, evaluated on the graph, before anything runs.

Ceiling Value What it bounds
N_max 10 000 in the browser; 100 000 on the server and in backtests the const length of a window or a vec. Beyond it: [ErrTotal]
maxNodes 3 072 per script the size of the graph
maxBricksPerBar 1 000 how many re-binned units one bar may produce (Renko, P&F). Beyond the cap the host aggregates rather than blow the budget
N_active 16 co-active scripts per chart how many scripts merge into one shared DAG; the aggregate ceiling is N_active × maxNodes
memory per instance the structural bound maxNodes × N_max the legal worst case. The declared footprint is the exact liveness plan, which is far smaller

The numbers are the least interesting part of that table. What matters is how they are enforced.

The graph is the authority, not the text. maxNodes is judged on the DAG after inlining, common-subexpression elimination and dead-code elimination — the graph that will actually run. The front-end guards (AST size, inlining expansion) sit far above it and exist only to keep a hostile input from exhausting the compiler; they never pronounce a budget verdict. nMax likewise never touches a computed byte: browser and server differ in what they accept, never in what they produce.

The memory ceiling is not a check — it is the allocation. The module declares its linear memory with min = max, sized from the liveness plan (§ memory model). Growth is not forbidden at run time; it is impossible. The static part is verified at compile time and the remaining lengths at instantiation — never while a bar is being stepped.

There is no per-bar timeout. Runtime cost is statically bounded by maxNodes × N_max × maxBricksPerBar: totality gives termination, and the ceilings give the practical bound. An over-budget graph is rejected at compilation, not killed mid-frame — the runtime guard below is defence in depth, not the enforcement path. A build does carry a wall-clock timeout, but it is an interactive cancellation in the editor, never a verdict: accept and reject stay a pure function of the source.

When the aggregate frame budget is nevertheless exceeded — many co-active scripts on one chart — the host applies a deterministic degradation policy, throttling or pausing low-priority scripts in an explicit declared order. The order is never data-dependent, so the degradation replays like everything else.

Where the work runs is a counted decision too. The service estimates a graph's cost in cost units and dispatches to a worker fleet only above a calibrated threshold; below it, a merged single-threaded pass finishes before a fleet would have started. The estimate is a pure function of the graph, so the routing is reproducible — and by 1 ≡ N the choice cannot change a byte either way.

#Fault isolation

A script that fails at runtime — an over-budget graph, a genuine error on a valid graph — is quarantined: its node is marked and removed from the active graph, without killing the worker and without disturbing its neighbours, which share the same instance map. Restarting the worker is the last resort, and the neighbours resume from their last checkpoint.

A NaN is not a fault. It is na, and it is displayed as a gap.

#The verification harness

The harness is a first-class deliverable, not a folder of tests. Each sub-suite declares its oracle, its corpus, and whether it blocks the ship:

Suite What it asserts Blocking
Goldens every example is a deterministic golden; an unchanged golden stays byte-identical yes
Properties principality, confluence (the kind is invariant under any topological order), incremental re-typing ≡ full inference, totality, causality, the memory plan is a deterministic function of the graph with peak ≤ sum yes
Fuzz + a well-typed generator the parser and the type checker are total (any input yields one tree or a clean rejection); the generator samples the frozen grammar and the lattice to emit type-correct, causal graphs that feed the oracle yes, once it feeds the oracle
Differential oracle (three ways) interpreter ↔ WASM ↔ native kernel — covering I6, optimized ≡ reference, and I7 yes
Metamorphic the enumerated semantics-preserving relations: optimized ≡ reference · interpreter ≡ WASM ≡ server · 1 ≡ N workers · peak-plan ≡ sum-plan · confluence under any topological order · recompile ≡ recompile, byte-identical · the absolute draw-list is invariant to target and sequence yes
Stress 1 ≡ N the same graph under one worker and under many, with randomized adversarial assignment: identical output bytes, and no concurrent slot write yes
Lattice enumeration the laws and every admissibility judgment, enumerated per family yes
Capability monitor "no command outside the manifest is ever executed" — the one component that earns a model check yes
Bench / budget calibrates the cost model with measurements; guards the idle-compile budget; checks that the runtime peak equals the planned peak advisory

One subtlety worth stating: the three-way oracle calls the same pinned routine on all three sides, so it is blind to a bug inside a pinned routine. That is why each pinned routine also carries a second, independent reference implementation, compared bit for bit on fuzzed input. The oracle catches disagreement; only a second implementation catches a shared mistake.

#Reproducible builds

The build hash is a pure function of: the source, the lockfile (the transitive closure of dependency hashes), the compiler version, the pinned routines, and the canonical memory plan.

Pinning the inputs is necessary but not sufficient, so the rebuild gate closes the gap: it recompiles the same source and lock twice, on different machines and with different thread counts, and asserts the emitted module is byte-identical to the stored hash. Server-side replay depends on this: a non-reproducible build would break replay silently, because a value-level oracle cannot see the bytes emitted across two compilations.

#Performance — measured, and honest

Why WebAssembly argued that the target was not chosen for speed. The certification benchmark settles what the speed actually is — measured on one Apple M4, over a hundred thousand bars, with the TypeScript, interpreter and WASM legs proven byte-identical before a single timing is taken. So every row below compares the same algorithm: the TypeScript leg is the interpreter's own kernels, called directly on a plain Float64Array, not a naive rewrite. And against that, in batch — the path every chart takes — the compiled module is faster everywhere, by a margin that grows with how much intermediate data the indicator moves:

Workload — the same algorithm on both sides How much faster than TypeScript
Trivial O(1) kernels — rsi, change ≈ parity — V8 compiles a tight f64 loop nearly as well
Weighted scans and deques — wma, alma, highest ×1.3–2
Realistic charts — a classic eleven-plot chart runs ≈ 21.5 ns/bar ×1.6–2.5
Order-p scans and reductions — percentrank, kama ×2–3
Multi-stage composites — fisher, connorsRsi ×3.7–5
Heavy multi-output composites — stdErrorBands ×9–14

How much faster the module runs than the same algorithm in TypeScript

Why — the datapath, not the engine. Decompose the gap and the execution engine is the small part of it. Wasm is machine code with single-instruction f64 ops and no dynamic bounds-checks on a linear memory proven in range at compile time — but V8's TurboFan compiles a hot, monomorphic typed-array loop nearly as well, so the engine difference alone is only ×1.1–2, which is exactly why the trivial kernels land at parity. The lever is the datapath. A multi-stage indicator hand-written in TypeScript materialises a full Float64Array per stage, writing then re-reading its values each time; the flux compiler fuses every stage into one pass, holding the intermediates in locals and rings. stdErrorBands' TypeScript leg allocates eight arrays; the module allocates none — and every materialised intermediate is two full memory passes of pure overhead. That, not the instruction set, is where the ×2-to-×14 comes from. (Compilation itself is tens of milliseconds, almost all of it Binaryen lowering the graph to bytes — an editor-time cost, paid once, never per bar.)

And beneath it, the algorithms keep the bytes. A rolling maximum reads a monotone deque instead of rescanning its window; a reset touches only the live geometry, about 28.5 µs down to 0.8 µs; a bounded length knob shrinks a script's state by roughly twenty times; an sma and a sum that coincide share one ring. Every one is an O(n) change with the same output bits — a win the optimizer and the native kernels deliver in any language, and one more reason the figure above is not a story about the execution engine.

The one case TypeScript wins — and why it is not a headline. Stream a single O(1) kernel that has a native f32 stepper — an ema or rsi advanced bar by bar — and the native library wins, ×5–8: flux pays a call across the WASM boundary on every bar where the library just takes a step. But that leg runs in f32, not f64 — a different-precision result, recorded as a documented divergence and never quoted as a speed headline. And it exists only where a native stepper does: a windowed wma or a deque highest has none, so that path recomputes in batch, where the module takes it all back.

Why SIMD is off the table — by choice. The one lever that would move scalar compute is SIMD, and byte-identity forecloses it: f32x4 packs four lanes into one, and a horizontal reduction reassociates floating-point and drops precision, so the bits change. Same-bits-everywhere is what no-repaint, replay, the goldens and the server's re-execution all stand on; trading it for a fraction of a factor would spend the property that makes these numbers mean something. It is a decision, recorded and enforced, not a feature still to come — the same line the optimizer draws one level down when it forbids reassociation.

So what WebAssembly is for. The speed is real, but read the decomposition again: it comes from the fused dataflow the compilation model gives you — not from WebAssembly as an execution engine, which is only the ×1.1–2. The reasons to target WebAssembly specifically are the four structural ones in Why WebAssembly: one execution artifact, so the interpreter and the module can be held equal from the start; floating-point specified across machines, so a server can re-run a client's work and catch a lie; one format that carries an indicator, a scene and a whole application pane alike; and a sandboxed, opaque binary to distribute, source withheld. Flux is fast because it compiles a fused dataflow graph — and it compiles that graph to WebAssembly for correctness, determinism and distribution. A benchmark you can reproduce is a better argument than a superlative.

#See also

↑ contents

Memory model

Flux has no garbage collector, no runtime allocator, and no way to run out of memory while running. That is not an optimization — it is a consequence of the language. Every buffer has a const-folded size, every lifetime is statically exact, and the compiler therefore does not check a memory ceiling: it computes the allocation. A program that would exceed its budget is rejected before it runs, never killed while running.

This page describes how values are represented, how the data path is laid out, how the liveness plan turns a graph into a memory map, and which bounds are enforced where. It distinguishes what is implemented today in the analysis-plane backend from what the sealed design specifies for the planes still being built.

#Value representation

At runtime, every scalar is an IEEE-754 double. Kinds are a compile-time discipline: once the dimension has been checked, a price and a level are both an f64. Nothing about the kind survives into the data path — which is precisely why a dimensional type system costs nothing at runtime.

#na and its canonical bit pattern

na is a NaN. That is convenient — arithmetic propagates it for free — and it is a trap, because the WebAssembly specification leaves the sign bit and payload of a produced NaN undetermined (0/0, sqrt(-1), ∞ − ∞). Two engines could therefore store different bytes for the same absent value, and byte-identity would break silently: no program can observe a NaN payload (is_na is a x ≠ x test, payload-insensitive), so only a byte-level oracle would ever see it.

The rule, implemented on both sides:

So na behaves like an ordinary absent value in the language, and like one exact bit pattern everywhere it can be compared.

#Decimals

decimal(scale) is a scaled integer, not a float. The backing width follows the declared precision — i64 for up to ~18 digits (a native WebAssembly type, eight bytes, the fast path), i128 by default, i256 for the largest crypto magnitudes.

Storage width is not compute width. A product promotes its intermediate (i64 × i64 → i128) and then re-quantizes to the declared precision of the destination, so an overflow is impossible to hide. Exceeding the declared bound yields na plus a diagnostic — never a wraparound, never undefined behaviour.

Why the threshold follows the declaration. The numeric digits of a result do not depend on the backing width; only the point at which "this is out of domain" fires does. Declaring decimal(18, s) means "beyond this, it is a domain error, not a bigger number". Because that threshold decides when an na appears, the declared precision is part of the script's hash: two parties replaying the same program must agree on when absence begins.

#Strings

A string is immutable, UTF-8, and bounded by a declared cap. Its unit is the Unicode scalar — never a byte, never a UTF-16 code unit — so that indexing and slicing agree across engines, and truncation always cuts on a scalar boundary.

Most strings in practice are short (a label, a formatted price), so they live inline in the value — a small-string optimization, zero allocation. Longer ones go into a bump arena reset once per evaluation tick (per bar, or per frame): no GC, because purity plus bounded lifetimes make the reset always safe.

One rule keeps that safe under replay: a string that survives its tick — captured by a scan, stored in a Model field, written into a checkpoint — is materialized out of the arena (copied), never left as a view into memory that is about to be overwritten. Without that, scrubbing backwards in the debugger would read an arena rewritten a thousand times since.

#Aggregates

Kind Representation
vec(κ, N) a contiguous span of N elements of κ. N is a capacity, so a shorter vector inhabits a longer one with an na tail.
record{…} a flat struct — in the data path, a group of parallel columns, one per field
variant{…} a tag plus its payload
Map, Set, Deque, Tree bounded arena structures, ordered, no hashing — see collections

#The data path is columnar

The engine evaluates a graph over bars, and it does so column by column, not row by row. Each node produces one Float64Array of length equal to the bar capacity; sinks carry their own columns. Records are struct-of-arrays: a bollinger node is three columns, not an array of three-field objects.

Columnar data path and ring state
Figure — nodes produce columns; a windowed kernel rings its history, and most rings derive their position from the bar counter while two families persist a cursor.

Two consequences worth naming:

The state plan counts each of them: a derived position costs nothing, a cursor costs a cell. And since a checkpoint is a memcpy of the whole state region, those cells travel with it — the ring of an rsi and the head and tail of a highest are in the snapshot, not reconstructed from the bar counter on the far side.

#The linear memory map

The compiled module owns one linear memory, internal and exported, with this layout:

The linear memory map
Figure — the plan is the allocation: min = max pages, and growth is impossible by construction.

[ header: bar count · write index ][ state cells ][ 6 bar columns ][ sink columns ]

The header is two i32 fields, and the second earns its place. The first is the bar count. The second, four bytes further in, is the write index of the columns — how many bars have actually been written. In a batch run, and in a live run over history, the two are equal and nothing observable separates them. Under a window they part company: the write index is the count of window bars written, and every column read follows it, not the bar count. They share one header cell, so a snapshot and a reset cover both at once.

The sink columns are the observable output, and their set is closed: plot, mark, fill, colorBars, alert, assert.

Everything in it is sized at compile time, from the declared bar capacity and the resolved parameters. The memory is declared with min = max = ceil(layout / 64 KiB) pages, so growth is not merely unused — it is impossible. The ceiling is not a runtime check; it is the allocation.

This is what makes the budget honest. A pane's footprint is known before it opens: the bounded Model, plus the view arena, plus the graph's own plan.

Why parameters are resolved at compile time. The unit of compilation is (graph, resolved parameters, bar capacity). Changing a knob recompiles and re-gates. The reward is a 100 % static state layout, an exact min = max memory, and — the load-bearing part — a byte-identity gate that validates the very bytes and the very instance that will serve. There is no gap between what was validated and what runs.

#The liveness plan

The graph is pure, total, causal and free of aliasing, and every buffer has a const-folded size. Therefore every buffer's lifetime is statically exact: its first use and its last use can be read off the schedule. The compiler exploits that with a mandatory liveness pass.

Peak, not sum
Figure — two buffers whose intervals do not overlap share one slot; the reported footprint is the peak of what is simultaneously live, never the sum of everything allocated.

The pass has three steps:

  1. Compute each buffer's interval [first use, last use] on the canonical topological order — the single linearization obtained by breaking every tie between ready nodes with the pinned lexical node identity (the same identity that anchors hashing and the random generator's draw index, invariant under inlining, dead-code elimination, common-subexpression elimination and recompilation).
  2. Classify. A buffer is live-out — kept for the whole instance — if it feeds an observable output, or if it survives its tick (captured by a scan, a Model field, a checkpoint). Otherwise it is transient and recyclable the moment its last use passes.
  3. Colour the intervals. Transients are assigned slots by greedy interval colouring within a size class: two buffers whose intervals are disjoint share the same slot, in an assignment order pinned to the canonical rank. Never a first-seen index, never a hash iteration order, never an address.

The reported footprint is then the peak of simultaneously-live bytes, not the sum of every buffer that ever existed. (The maxNodes × N_max product remains a sum bound — it guarantees termination and compile-time rejection, and it is deliberately not the same number as the peak.)

In the WebAssembly backend the plan does double duty: one f64 local per liveness slot, so the plan is literally the local-allocation map, and the peak is far below the node count — especially once several scripts are merged into one graph.

In-place donation (sealed design — the shipped planner today shares slots only between disjoint lifetimes; donation is specified but not yet emitted). A functional node (vec.setAt, a column derivation, a record update) with a single consumer at its last use would write in place into the slot of its dying input instead of copying — opt-in, and gated by the translation validation so that an unsafe donation (two consumers, or a source still live) is a build failure, never a silent overwrite.

Why the plan itself must be deterministic. The value oracle compares outputs; it is blind to layout. Slot sharing is value-invariant (a slot is only reused after its occupant's last use, so no read ever sees an overwritten value) — so two different plans would produce identical outputs and the oracle would notice nothing. But the plan is baked into the emitted module. If it were not a deterministic function of the graph, two compilations of the same program would differ in bytes. The plan therefore joins the pinned routines — the pinned mathematics, the decimal routine, the Unicode tables, the calendar conversion, the random generator — as something that must be identical on every engine and every machine.

#Bounds and budgets

Bound Value Nature
N_MAX 10 000 (browser target) the maximum window, period or delay of a kernel
N_MAX_SERVER 100 000 the same bound, server/backtest target
MAX_NODES 3 072 the size of the graph — judged on the graph after inlining, elimination and common-subexpression elimination
N_ACTIVE_MAX 16 co-active scripts merged into one global graph
maxBricksPerBar 1 000 the cap on how many boxes one time bar may cross in a price-driven representation

Three properties of these numbers matter more than the numbers themselves.

The window bound is a validation bound, not a compute parameter. It decides acceptance; it never enters a computation. A program that compiles under two different values of N_MAX produces identical bytes, and its memory is sized by the periods it actually uses. That is what lets the browser and the server carry different ceilings without forking the language: the compiler records the program's real maximum length, and the loader gates environment ≥ program. A server pack with a 50 000-bar period is refused outright in a browser — cleanly, at load — instead of failing at instantiation.

The graph bound is judged on the graph. The front-end guards (on the syntax tree, on the inlining expansion) sit at 64× the graph budget: they are a compiler anti-abuse measure, not a verdict on your program.

The compile verdict is deterministic counters only. Acceptance or rejection is a pure function of the source. There is an interactive build timeout in the editor — around two seconds — but it is a cancellation, never a verdict. A wall-clock verdict would mean the same program is accepted on one machine and rejected on another, which would break replay and anti-cheat at the root.

There is likewise no per-bar timeout. Runtime cost is statically bounded by maxNodes × N_max × maxBricksPerBar; totality gives termination, and those ceilings give the practical bound. Exceeding the budget is a compile-time rejection — a program is never killed mid-bar.

#Isolation: panes, workers, arenas

The worker layer described here is shipped: the graph is partitioned, scheduled and run across a pool today, and the 1≡N proof in Concurrency is the reason that is safe. Two things in this area are Post-v1. — the application realm (the per-pane Model and its view arena) and the shared compute module. Both are marked as such where they appear below, and neither is a description of the runtime.

One module instance per task, with its own memory. This is the shipped isolation, and it is the strong form of it. A task is handed the compiled bytes and instantiated on the worker that will run it. The module declares its own internal linear memory, min = max pages, and it is instantiated against function imports only — the pinned transcendentals, nothing else. It imports no memory, and there is no memory for it to import: not one linear memory in the runtime is declared shared.

Shared, read-only: the six input columns. The host writes time, open, high, low, close and volume once, into a single shared array buffer, and every worker reads its bars from there. That is the whole of the sharing.

Owned, write: everything a task produces. A sink column is computed in the module's private memory and handed back by buffer transfer — zero-copy, and the sender loses the buffer as it gives it away. Never by sharing. Which is what lets the next rule be absolute: no atomic ever touches data. Atomics serve only the scheduler's claimed and done counters, in a small buffer of their own.

Arenas are per worker. The scratch arena for transient buffers is private to each worker, sized to the peak of that worker's sub-graph. Under the rule above it could hardly be anything else — nothing writable is shared, so there is no shared arena to fall into.

Why per-worker arenas are load-bearing. The liveness plan reads disjointness on the sequential canonical order. Two buffers may legitimately share a slot because their intervals do not overlap there — and yet, under a dynamic scheduler, the nodes that own them can execute at the same time on two workers. A shared arena would give them the same address: a write-write race, and a byte-identity failure that no value oracle would catch. Private arenas make the aliasing impossible by construction rather than by scheduling discipline.

Post-v1. A shared compute module. The eventual design has a different shape, and it is worth stating precisely so that nobody reads it back into the runtime. One shared compute module — the kernels, the engine core — instantiated per worker against one shared linear memory, each worker addressing its own region by offset; each application module then imports it instead of recompiling the kernels into itself, and so carries only its own logic: small, fast to instantiate, independently invalidated. None of that ships. Today a kernel that carries state between bars is inlined per node into the module that uses it, and the pure ring-scans that remain shared functions are shared within one module, never across two. The rewrite would trade zero writable sharing for region-disciplined sharing and reopen the snapshot, the windowing and the verification machinery to do it — for a future the current runtime does not need yet. The reasoning is set out in Concurrency.

Post-v1. One instance per pane. An application pane is a WebAssembly module instantiated for that pane — its own linear memory, no DOM, host access only through vetted imports. Closing the pane releases the instance and everything in it. Two levels, deliberately distinct: the application realm (a Model, a view arena) is isolated per pane, while the compute pool runs the graph. Confusing the two is the classic mistake here.

#Checkpoints and snapshots

Because all state lives in one contiguous region with a static layout, a checkpoint is a memcpy of that region — and a restore is the same copy back. Resumption is byte-exact, which is what makes debugging time-travel, live-preview scrubbing and server-side replay the same mechanism rather than three approximations of one.

At the serialization boundary the canonical na rule applies, so a snapshot taken by one engine is byte-identical to one taken by another.

#The APP plane: bounded models and slotmaps

Post-v1. An application's Model admits only bounded kinds — so its footprint is computable at compile time, exactly like a graph's. Its variable collections use the slotmap pattern: a bounded vector, tombstones instead of compaction, a live mask, and a free list held in a parallel index vector so that the tombstone itself is never overwritten. Nothing is ever moved, so no handle is ever invalidated, and the memory plan stays flat. See App plane.

#What does not exist

No garbage collector. No runtime allocator. No fragmentation. No out-of-memory at runtime. No "it was fine on my machine". A program either fits its declared budget — and then it fits it on every machine, byte for byte — or it does not compile.

#See also

↑ contents

Optimizer

Flux's optimizer is aggressive, and nobody has to trust it. Those two facts are the same fact, and this page explains why.

The reason a compiler's optimizer is usually a source of anxiety is that its correctness is argued, not checked: a rewrite looks sound, it ships, and three years later someone finds the input for which it was not. Flux takes the other road. The reference semantics of a program is the canonical evaluation of its unoptimized graph, and every compilation checks the optimized module against it, bit for bit, on hostile data. An optimizer that cannot be trusted is fine — what matters is that a miscompilation cannot ship.

#The law

Reference semantics = the canonical evaluation of the unoptimized graph. At every compilation, the gate runs that oracle against the emitted module of the optimized graph — in batch, then bar by bar through the live path, over a mixed-seed hostile corpus and, where it is available, over real data — and demands bit-exact equality on every sink column.

One comparison covers the optimizer and the code emitter, end to end. It is the same gate that enforces interpreter ≡ WASM (compiler and runtime), doing double duty: the oracle it compares against is the unoptimized evaluation, so a rewrite that changes a value by one bit fails the same check that a bad instruction selection would.

When the optimizer changes nothing, the gate costs nothing — an identity fast path skips it entirely. When it fires and passes, the cost is the oracle run, which was already being paid.

#When a rewrite is wrong

If the optimized attempt diverges, the compile retries with the unoptimized graph and serves that, carrying a diagnostic. Two consequences, and both are deliberate:

#The honest coverage bound

The gate proves equality over the corpus's value coverage (its hostile zones: na, ±infinity, negative zero, ties, extreme magnitudes, subnormals, near-overflow) and over knob periods up to a sweep ceiling — a deliberate anti-abuse trade-off, since sweeping every period of every knob on every compile would be a denial-of-service on the compiler itself.

So a rule whose divergence only manifests at an effective period beyond that ceiling would pass the per-compile gate. That is not a gap we paper over: it is the reason the rule charter carries an explicit proof obligation for exactly that class of rule (obligation 5, below). Saying "the gate proves everything" would be more comfortable and less true.

#The rule charter

Every rewrite rule must satisfy all five, and must document its argument for each:

  1. Bit-exact in IEEE-754 f64 for every input — including the na paths, signed zeros and infinities. No floating-point reassociation, ever.
  2. Purity and order. Rules rewire pure dataflow. A stateful node (a delay, a crossing, a kernel) may be shared or replaced only when the replacement provably produces the identical state trajectory — same dependencies, same parameters.
  3. Determinism. No data-dependent and no environment-dependent decision. First match by table order. Every rewrite goes through the hash-consing rebuilder, so the same graph always rebuilds the same way.
  4. The ABI is untouchable. A rule may never eliminate, merge, retype or reorder an input node — the parameter block is a contract with the host, and an optimizer that "helpfully" dropped an unused knob would break every saved chart.
  5. Period-scaling proof. Any rule that touches a kernel, a delay or shared state ships with a dedicated test at the real maximum period, because the per-compile gate only proves periods up to the sweep ceiling. Element-wise peepholes are period-independent by construction and are exempt.

#The rules that look sound and are not

This table is the most useful thing on this page. Every one of these rewrites appears in textbooks; every one of them is wrong in IEEE-754, and Flux rejects all of them:

Tempting rewrite The input that kills it
x + 0 → x x = −0. Then −0 + 0 = +0, which is not −0.
0 − x → neg(x) x = +0. Then 0 − 0 = +0, while neg(+0) = −0.
select(c, x, x) → x c = na. The result is na, not x.
x − x → 0 x = na or ±∞. The result is na.
x * 0 → 0 x = na or ±∞na; and x = −1−0, not +0.
(a + b) + c → a + (b + c) Reassociation changes the rounding. Forbidden outright.

And the ones that are sound, each with its witness:

x * 1 → x · 1 * x → x · x / 1 → x (multiplication and division by exactly 1.0 are exact) · x + (−0) → x (because +0 + −0 = +0 and −0 + −0 = −0) · neg(neg(x)) → x (flipping a sign bit twice is the identity) · x + x → 2 * x (the same rounded operation).

Why negative zero deserves this much respect. It is not a curiosity. A value of −0 arises constantly in real data (a difference that rounds to zero from below), it compares equal to +0, and it prints as 0 — so a rewrite that turns one into the other looks correct in every test a human writes. It is only visible to a byte-level oracle, which is precisely why the byte-level oracle exists.

#The pass engine

The rules are the interesting part; the engine that runs them is the part that must never be interesting. Four invariants hold it flat.

Every pass rebuilds, and the rebuilder hash-conses. A pass does not mutate the graph in place — it reconstructs it in topological order through the same structural key the lowering uses (one key function, one source). Two consequences fall out for free. Cascading common-subexpression elimination after a rewrite costs nothing: if a rule makes two sub-graphs identical, the rebuild is the merge. And a graph that arrived un-eliminated — a forged one, or one built by hand — is normalized on the way through. The single exception is the input node, which is never hash-consed: two knobs with the same default are two knobs, and collapsing them would silently merge two settings a user can move independently.

Renumbering is monotone. The relative order of the surviving nodes is preserved through the dead-code sweep. That is not cosmetic. The parameter block's cells are laid out in input-node id order, so a pass that permuted ids would move a knob's cell underneath a host that had already bound to it. Monotone renumbering is what keeps the knob cells stable across optimization.

Termination is bounded, and failure is the identity. Passes repeat to a fixpoint — zero rule hits and zero structural compaction — under a ceiling of eight passes. The ceiling is generous by a wide margin: the deepest rewrite cascade the rule table can produce is three deep. And the engine is total. A rule that throws, a rewrite that produces an invalid node id, a post-condition that fails — any of them returns the input graph, unchanged, flagged, and the compile serves the unoptimized path. The optimizer has no failure mode that is not "the optimizer did nothing".

The post-conditions are re-checked, not assumed. After the last pass, the shape validator runs again on the optimized graph, and the memory plan (A13) is re-derived from it rather than carried over — a pass that changed the graph changed the liveness, and a stale plan would be a buffer-sharing bug that no value oracle could see. A remap records where each original node landed; an id absent from it is a node the optimizer proved dead.

Why the engine is total rather than correct. These two paragraphs describe an engine that is allowed to fail, at any point, for any reason — and whose failure is indistinguishable from having done nothing. That is a deliberate inversion. We do not attempt to prove the pass engine right; we make it structurally incapable of shipping a graph it is not sure about, and we point the byte-level gate at whatever it does produce. Correctness by verification, totality by construction.

#The tiers

T0–T1 — bit-safe, and shipped:

Pass What it does
native kernel dispatch a leaf becomes the native kernel — the mechanism of byte-identity itself
global common-subexpression elimination the big one: identical sub-expressions are computed once
dead-code elimination a value nobody reads is never computed
constant folding const-folded literals collapse
order-preserving fusion an element-wise chain becomes one pass
recursive / windowed / batch selection choose the cheapest evaluation form — the one the native kernel already uses
buffer sharing the liveness plan: disjoint lifetimes share a slot (memory model)
zero-allocation hot loop every buffer is allocated once, from the graph
live O(1) causality makes an incremental step cheap
elimination across scripts the co-active scripts are merged into one graph, so a sub-expression they share is computed once for all of them

#Elimination across scripts — the pass that actually pays

The last row of that table deserves its own section, because it is where the redundancy in a real chart lives. A chart does not run one script. It runs the handful you have open, and they overlap: two indicators both want ema(close, 200); a strategy and its filter both want the same sma.

So the co-active scripts are merged into one graph and optimized together. Elimination then crosses the script boundary without knowing there was one: an ema(close, 200) in two scripts is one node, computed once. State collapses with it — an sma in one script and a sum of the same source and period in another end up sharing a single ring buffer rather than two.

Three rules keep that sound, and each closes a specific way it could have gone wrong:

The set is bounded — at most sixteen co-active scripts, each within the ordinary node budget — because past that point the aggregate frame budget and its degradation policy (concurrency) are the right instrument, not a bigger merge.

Two honest boundaries. A closed pack ships a module and no graph, so there is nothing to merge it into: it is excluded by construction rather than by policy. And the merge applies to the batch path; the bar-by-bar live path advances each script's own module, so a co-active set shares its compilation and its state, not its live step.

The canonicalization that looks like a pessimization. sma(x, p) is rewritten to sum(x, p) ÷ p — unconditionally, and not because a division is cheaper. It is a normalization: it makes an sma and a sum over the same source and period the same node, which is what lets two scripts share one ring. The rewrite carries a domain guard (the period must be a constant, or a knob with declared bounds), because outside that domain the two forms clamp differently — obligation 2 of the charter, discharged by restricting the rule rather than by hoping.

#T2 — the aggressive tier, and what became of it

The premise was an unusual one: Flux scripts are short. A graph of a few dozen nodes fits inside a sub-16-millisecond budget even under passes that are normally infeasible — so the target could be the optimum rather than "good enough".

Two of those passes remain designed and deferred. The third was investigated and rejected, and the rejection is worth more than the pass would have been.

Post-v1. Optimal scheduling and fusion — an exhaustive search, tractable at this size — and specialization and partial evaluation from constants and kind bounds.

Equality saturation: no. The pass is the classic answer to "apply all rewrites at once": build an e-graph, union every equivalent term into it, then extract the cheapest member under a cost model. It is provably equivalent, and on the right rule table it is genuinely stronger than a fixpoint. On this rule table it is stronger than nothing, and the case rests on two independent legs:

The verdict is a test, not a paragraph. The orthogonality that leg one rests on is a condition, and conditions rot. So it is asserted permanently, in the suite: a probe over the grammar corpus that fails the moment a future rule overlaps an existing one; an empirical confluence check that drives the rules in adversarial seeded orders and demands they all land on the same node count and cost; and a bound on the rewrite cascade depth. A failure of the first probe does not just fail a test — it invalidates this decision, and reopens the pass.

The named reopen conditions, in the order they are likely to arrive: a rule whose left-hand side overlaps another's; a tolerance mode (@fast, below), which admits the generative classes and with them the search space saturation was built for; a rule whose two forms genuinely emit differently, collapsing the second leg; or a divergence in the confluence check. Any one of them, and the pass comes back — with cost-driven extraction, and with the tie-break below.

What a reopening would have to ship on day one. When two extractions have equal cost, the tie must be broken by the pinned lexical node identity — the same identity that anchors the memory plan and the random generator's draw index. Otherwise two extractions of equal cost would emit different bytes, and the value oracle — which compares outputs, not layouts — would never notice. It is written down here so that it is a prerequisite rather than a discovery.

Post-v1. T3 — opt-in, never the default: @fast relaxes floating-point (reassociation, fused multiply-add). It is faster and it is not bit-exact, so its goldens would carry a tolerance. The default stays deterministic, because a charting language's value is its no-repaint, its replay and its goldens — and all three are byte-level properties. It waits on a bench case that shows the relaxation is worth what it costs; the audit so far puts scalar f64 within a small factor of the relaxed form, which is not a case.

#The rules that ship, in the WebAssembly they change

The tiers name the passes; here they are from the other side — each rule as it exists in the compiler, and, for two of them, the WebAssembly before and after. The WAT is hand-written for reading (series are shown as locals; the emitter loads them from memory columns), but the shapes are the ones it produces. Every rule carries its written IEEE argument — the charter's first obligation — and the element-wise ones are period-independent, so the period-scaling proof does not apply to them.

The peepholes. Local rewrites, each exact for every input:

The passes with no rule table — they are the machine.

Strength reduction, ÷2×0.5. The average of the bar's high and low —

fluxplot (high + low) / 2

— lowers to a divide, then becomes a multiply by the exact reciprocal:

;; before — the division as written
local.get $high
local.get $low
f64.add
f64.const 2
f64.div

;; after — same bits, cheaper instruction
local.get $high
local.get $low
f64.add
f64.const 0.5
f64.mul

The two forms are bit-for-bit equal because 2 and 0.5 are both exact, so each rounds the same real number once. That equality is not argued: at every compilation the gate re-runs the unoptimized graph on the interpreter and compares. (The interpreter and the module are the FVM's two conforming implementations, bound by I7.)

Common-subexpression elimination, one node from two. Feed the high-low range into two plots —

fluxplot (high - low) * 2
plot (high - low) + close

— and the naive graph would compute the subtraction twice; the rebuilder emits it once, into a slot, and both readers take it from there:

;; before — the range, recomputed
local.get $high
local.get $low
f64.sub
f64.const 2
f64.mul
local.get $high
local.get $low
f64.sub            ;; the same work, again
local.get $close
f64.add

;; after — computed once, reused
local.get $high
local.get $low
f64.sub
local.tee $hl      ;; keep the range in a slot
f64.const 2
f64.mul
local.get $hl      ;; reuse it — no second subtract
local.get $close
f64.add

The same machine, run across the scripts you have open, is what turns a shared ema(close, 200) in two indicators into one kernel and one ring — the cross-script merge above, where the redundancy in a real chart actually lives.

The table stays short on purpose. A rewrite being sound is necessary, not sufficient — it also has to earn its slot. x + x → 2·x is sound (the same rounded add), but it trades an add for an add plus a constant, so it buys nothing; x + (−0) → x is sound too, but the pattern does not arise in real graphs. Both are left out on cost, not on doubt — the same restraint that keeps the rule set orthogonal and the aggressive tier closed.

#The ABI is a contract — and so is the provenance

Charter obligation 4 says a rule may never touch an input node. This section is what that obligation buys, and what enforces it when a rule author forgets.

The guard is mechanical. After optimization, the image of the input nodes must be total, injective and still input-typed — every knob still there, no two collapsed into one, none retyped. A rule that violates any of the three does not produce a diagnostic and continue: the whole optimization is discarded as the identity, and the compile serves the unoptimized graph. Lifting that guard is not a rule change; it would be a decision to version the parameter schema.

Public surfaces speak the unoptimized graph's ids. The optimizer renumbers, but nobody outside it ever sees those numbers. Knob descriptors and parameter cells are rewritten back to the original ids on the way out, through a back-map the injectivity guard is precisely what makes well-defined. A host that saved a chart against knob 3 finds knob 3 where it left it, whatever the optimizer did in between.

Presentation and manifest derive from the unoptimized graph — on both sides. A package's declared presentation (panes, scales, reference lines, series names) and its capability manifest are derived from the O0 graph, at build time and at verification time. The optimizer affects the module's bytes and nothing else; no optimized graph is ever serialized or shipped. This is what keeps the two derivations comparable: a verifier that re-derived presentation from an optimized graph would be comparing against a graph the author never wrote.

Provenance holds by construction, not by promise. Build and verify go through the same compile entry point, hence the same optimizer, hence the same bytes — which is why a rebuild can be checked at all. Two consequences follow, and both are sharp:

The toolchain is part of the identity. The compiler version is stamped into the package manifest, and it is also one component of the single canonical key under which a compiled artifact is cached — alongside the pinned Binaryen version, the pinned maths library, the emitted WebAssembly feature set, and the program itself. One key, one source. From the first published artifact onward, any change to the rule table or the engine that alters emitted bytes must move that version: a cached module compiled under a different rule table is a module that was gated against a program the compiler no longer produces.

Why provenance is not correctness. It is tempting to read "the shipped bytes are the toolchain's compilation of this source" as "the shipped bytes are correct". It is not. Provenance guarantees the two sides ran the same compiler; it says nothing about the periods that compiler never swept (the honest coverage bound). Both sides carry the same bound. Conflating the two would be the most comfortable mistake on this page.

#The honest ceiling

The kernels stay native. So the optimizer works at the graph level — redundancy, scheduling, specialization — and never inside a kernel's arithmetic. Floating-point reassociation is forbidden by default. Therefore:

Claiming a speedup on the simple case would be marketing. The optimizer's real job is that the complicated case does not cost what it looks like it costs.

#The cost model

The cost of a node is not guessed: micro-benchmarks measure it, and the measurements calibrate the table. The same model is shared by the optimizer and the scheduler, so that "is this node worth a worker?" and "is this rewrite worth it?" are answered from one set of numbers rather than two sets of opinions.

The editor shows you the result: a cost gutter on the optimized graph, so what you read is what you pay.

#See also

↑ contents

Concurrency

Flux runs on many cores, and the author never writes a lock, an await, or a thread. That is not a convenience API hiding the hard parts — it is a consequence of the language: a pure, total, typed dataflow graph can be scheduled onto any number of workers without changing a single bit of its output.

This page explains why that is true, what the scheduler actually does, and the one place where "there is no aliasing, so there is no race" would have been a fatal shortcut.

#The scheduler is not the compiler

The compiler produces a topologically sorted graph. A separate scheduler assigns its nodes to workers. Single-threaded and multi-threaded execution share the same graph — one is the degenerate case of the other, not a different mode with a different code path.

That separation is what makes the parallelism auditable: the thing being scheduled is exactly the thing the gate verified.

#Three classes of node

Parallelism is not applied uniformly. The intermediate representation classifies every node, and each class has exactly one legal treatment:

Class What is in it How it may be parallelized
stateless arithmetic, comparison, logic, select, a projection — everything that reads only this bar independent of every other bar, so it is data-parallel in principle. Never with SIMD — a horizontal SIMD reduction reassociates floating-point and changes the bits.
stateful a kernel, a delay, a crossing — everything that carries state cells between bars it is a series along time. Parallel only through an associative prefix scan, and only where the operation genuinely is associative.
reduction Reserved. No operation is in this class today. The seam is held open, inert. Reserved for the pairwise-tree clause below, which binds when matrix and linear-algebra operations arrive.

Two of those rows need their honest reading spelled out, because a table this tidy invites a generous one.

The reduction class is empty, and that is deliberate. It is not "the class the sums and the means go in" — a sum, a mean, a stdev carries state cells and is therefore classified stateful, like every other kernel: each aggregates internally, along time, through its own state. The reduction class exists so that the rule governing it — a reduction may be parallelized only through a frozen pairwise tree, never by reassociating its interior — is written down and enforced before the first operation that needs it, rather than argued about afterwards. The classifier is total: an operation it does not recognize is classified stateful, which is the conservative answer, because the failure mode of guessing wrong in the other direction is a silent data race.

Chunked data-parallelism over stateless sub-graphs is not in v1. The class permits it; the implementation does not do it. Real programs interleave their stateless operations into the stateful cone — an ema reads a difference which reads a close — so harvesting the stateless parts would mean a second emission path through the compiler for a gain that the cost model does not support. It is documented as future design, and the section below says what v1 does instead.

The rule underneath all three classes: the reduction order is preserved. Parallel floating-point reordering exists only under the opt-in relaxed mode, and it is never the default.

Why we give up the easy speedup. Summing a column with four threads and combining the partials is the first thing anyone tries, and it produces a different last bit. That bit is the difference between a golden that holds and a golden that drifts, between a server that can verify a client's work and one that can only approximate it. So the parallelism is found where it does not change a value: across independent nodes, across independent groups, across independent scripts — never inside a single reduction.

#The substrate

Web Workers, a shared array buffer, and atomics, behind cross-origin isolation — the one path that actually works in a browser. The doctrine on top of it has two levels, and the split is the whole of the memory safety argument:

Two rules keep that honest:

Why the module memories are not one shared memory. The eventual design puts one shared compute module against one shared linear memory, with each worker addressing its own region by offset. That is a real design and it is not this one. Today, one module instance per task with its own memory gives the same parallelism, gives zero writable sharing instead of region-disciplined sharing, and asks nothing of the snapshot, the windowing and the verification machinery that are already built against per-module memories. The shared-memory rewrite buys a future the current runtime does not need yet, and it would reopen three subsystems to do it.

#The unit of work is a component, not a node

Before a scheduler can assign anything, something has to decide what a task is. Getting that wrong is how parallel runtimes end up slower than sequential ones, and the arithmetic here is brutal: a node in this intermediate representation costs on the order of a nanosecond, while any hand-off between two workers costs on the order of a microsecond. Parallelizing per node, per bar, would spend a thousand units of overhead to save one. This is the concrete form of the "don't spawn a worker for a tiny node" rule that the shared cost model exists to answer.

So the v1 unit of work is a connected component of the graph, weighted by its measured cost per bar times the number of bars.

That definition earns its keep on the merged graph — the one the optimizer already builds out of the co-active scripts (optimizer). Merge sixteen scripts and the components re-separate along the real data dependencies, not along the file boundaries:

At fifty thousand bars, a component costs milliseconds — three orders of magnitude above the dispatch cost, which is what makes the whole exercise worth doing.

#The scheduler

The assignment is longest-processing-time-first. Sort the components by cost, descending, and give each to the least-loaded worker. Ties break deterministically — by component id, then by worker index — so the same graph always produces the same assignment. For independent tasks on identical workers, this is within 4/3 of the optimal makespan, which is the right point on the curve: a schedule nobody has to think about, with a bound nobody has to trust.

The barrier is per topological level. Level k+1 starts after a full barrier on level k; within a level every node is independent, so any assignment is correct. In v1 batch the task graph has no cross-task edges at all — components are independent by definition — so there is exactly one level, and the barrier contract holds trivially. The scheduler still emits its plan as levels, because that is the shape the future needs: when cross-task edges arrive with the matrix and prefix-scan pipelines, they slot into the same barrier without a redesign.

Barrier scheduling on the levels of the graph
Figure — within a level the nodes are independent, so the assignment of nodes to workers is unobservable and cannot change a byte.

Post-v1. Work-stealing — a Chase–Lev deque per worker plus an atomic in-degree counter per node, a worker taking a node the moment its in-degree reaches zero and stealing from a neighbour when it runs dry — is designed, and parked on bench evidence. It is a swappable assignment policy behind the same plan interface, and the same stress harness revalidates it. What it is not is a semantic question, which is the point of the next paragraph.

Why an upgrade of this kind is safe, precisely. Because the assignment is unobservable. Values are schedule-independent (the graph is pure), and the memory slots are schedule-independent too (the liveness plan is computed from the canonical order, not from the runtime). So moving from a barrier to work-stealing would be a pure change of assignment policy with zero change of value — a latency decision, not a semantics decision, which is exactly what you want a scheduler to be. That is also why it can be parked without hedging: nothing else in the design is waiting on it, and no guarantee is weaker for its absence.

#The trap: zero aliasing is not enough

Here is the mistake this design had to not make.

The liveness plan lets two buffers share a memory slot when their lifetimes are disjoint. Disjoint in the sequential canonical order — that is how the plan reads it. But under a dynamic scheduler, the two nodes that own those buffers can execute at the same moment on two different workers. A shared arena would hand them the same address, and a write-write race would follow — one that no value oracle could catch, because the divergence is in which garbage you read, not in the arithmetic.

Two rules close it:

#1 ≡ N is proven, not asserted

The claim "one thread and N threads produce identical bytes" is not a hope backed by testing. It follows from a list of properties, each of which is enforced elsewhere:

And then it is tested anyway, because a proof about an implementation is a proof about the implementation you think you have. The stress harness ships in v1: it runs the same graph under one worker and under many, with randomized and adversarial assignment, and asserts

The assignment seed is journaled, so an adversarial failure reproduces exactly.

What the harness is actually hunting. Not the arithmetic. The list above already settles the arithmetic, and no amount of stress would strengthen it. What can genuinely break is the plumbing, so that is what is stressed: that each task is claimed exactly once and never twice; that each sink column is written exactly once; that a module instance never serves two tasks at the same moment, and that reusing one across ticks resets its state; that a transferred buffer is never read after it has been detached. Those are the bugs a pure dataflow language can still have, and they are invisible to a value oracle — a duplicate claim computes the right number, twice.

And it runs twice, on two substrates: first in-process, against simulated workers and seeded adversarial completion orders; then unchanged, on real threads. That ordering is a diagnostic instrument. A failure that reproduces in-process is a bug in the logic — the partition, the demultiplexing, the claim protocol. A failure that appears only on real threads is a bug in the substrate — a transfer, an atomic, a measurement. Running the same assertions in both places is what lets a failure say which of the two it is, before anyone starts guessing.

#The browser is not a given

A shared array buffer requires cross-origin isolation, and cross-origin isolation requires two response headers that a page does not always get to have. So the fleet is not a foundation the rest of the design stands on — it is an acceleration that may or may not be available, and the design says so out loud:

#Budgets across scripts

A per-script node budget is not enough when a chart carries several scripts. Two more bounds apply:

Over budget, the response is a deterministic degradation policy. Tasks are deferred — never killed; the layer above reschedules them — until the remainder fits, and the order they are deferred in is a total order fixed in advance: ascending priority first (the priority comes from the host, and is never derived from the data), then descending cost at equal priority (deferring the biggest frees the most), then ascending index. A task that on its own exceeds the budget is deferred too — the budget is a hard contract, not a suggestion. A free task is never deferred, because deferring it would free nothing.

Read the tie-breaks again and notice what they are for. Every one of them exists to make the answer to "which script gets dropped" a function of the declaration and never of the numbers flowing through it. A degradation policy that consulted the data would make the set of scripts that ran depend on the market, and a chart whose composition changes with the data is a chart nobody can reason about — or reproduce. A frame that drops is a decision, made in advance, in one place.

The instance pool, and why eviction is boring on purpose. Workers keep a pool of module instances with a stable task-to-worker affinity, so a task that runs every tick — a live update, a replay step — finds its instance warm rather than re-instantiating it. The pool's footprint is accounted for exactly: the sum, per worker, of the peak memory each of its modules plans for, which the memory model already computes at compile time (memory model). And when the pool must evict, it evicts by an explicit priority order — never by what the data happened to touch most recently. Determinism is not a property you can have in the arithmetic and give up in the cache.

#What v1 delivers, and what it does not

Multi-worker execution, from the start — built, stress-tested, and shipped, with single-threaded as its degenerate case. Concretely, that is: the node classification, the partition into components, the longest-processing-time assignment over a level barrier, per-worker arenas, atomics confined to the scheduler's counters, the aggregate budget with its deterministic degradation, and the 1 ≡ N harness that holds all of it in place.

Three things are deliberately not in it, and none of them is load-bearing:

Not in v1 Why, and what it would take
work-stealing Post-v1. A latency optimization over an assignment that is already correct; parked until a bench case shows the level barrier is the thing costing the frame.
chunked data-parallelism over stateless sub-graphs The class permits it, the emitter would have to grow a second path for it, and the cost model does not currently justify the trade. Future design.
the pairwise reduction tree Reserved. The rule is written; no operation is in the class it governs. It binds when matrix and linear-algebra operations arrive.

The one honest deferral in this area that is not about scheduling: network transports that need a raw socket, which the browser cannot open at all.

#See also

↑ contents

Host integration — descriptors, registries and extension seams

Flux does not draw anything. It expresses content — indicators, representation transforms, drawing geometry, scenes, depth values — and the host applies runtime modes: a 2-D or 3-D projection, the chart type the user picked, the pane layout, the persistence. The language produces the artifacts; the modes consume them.

That split is the whole architecture, and it has a sharp practical consequence: a script and a built-in must be indistinguishable to the host. If a user's Point & Figure implementation registers itself in the same table, in the same shape, with the same hooks as the native candle renderer, then extensibility is not a feature bolted on the side — it is the same road the first-party code already drives on.

This page specifies the contracts at that boundary: what a descriptor is, what the registries promise, which two gaps in the host must close for representations to be scriptable at all, and which seams are deliberately held open for what comes next.

#The scope boundary

Inside the language — content, consumed by a registry or a descriptor:

indicators · representations (chart types) · authored drawings and custom drawing tools · canvas scenes and overlays · transitions · alerts · depth and 3-D values · panes, declaratively (inferred from kinds — there is no createPane()) · parameter UI (derived from input).

Outside the core — mutating application state: enabling another script, persisting, reconfiguring the application. That is the host's job. A script's interactivity stays cosmetic (on click -> spawn/tween/flash), bounded, and repaint-free; toggling the visibility of its own output is allowed.

A command layer — buttons that activate scripts, change the layout — exists, but as a separate declarative plane (the APP plane), never on the analysis plane. That is what preserves totality, the firewall, and no-repaint no matter how rich the surrounding application becomes.

#Four locks

Four decisions are expensive to retrofit and are therefore frozen up front:

  1. The x axis is an ordinal index plus a time mapping — never "the time". Position is dataX(i); the timestamp never enters the x computation.
  2. depth/z is a first-class coordinate, not a 3-D feature.
  3. The plane split and the descriptors are compilation targets, not conventions.
  4. Registries accept script-registered entries in the same shape as built-ins.

#The five descriptors

The five descriptors
Figure — four contracts tie the language to the registries and the clock; a fifth, cosmetic, drives transitions.

#① Clock / ordinal

A clock is a producer of a series: an ordinal index, a length, the bar store, and two mappings — timeAt(i) (index → time, the source of the time stream) and idxAt(sec) (time → index, round-to-nearest and clamped, used to anchor drawings).

Constructors: tf(token) is time-coarse aggregation; renko(box), pnf(box, rev) and range(r) are price re-bucketizers — the same slot, with a price threshold instead of a time one. @ routes to one of three paths: same step (a native no-op), coarser (a remap), finer (a sample at close).

Seven codegen invariants govern everything that compiles through this contract:

Invariant
I1 position is the index — never the timestamp
I2 idxAt is only a seed: the resample locator is a floor-containing pointer (Tₖ ≤ t < Tₖ₊₁), never idxAt(t) − 1. Round-to-nearest is look-ahead, and look-ahead is repaint.
I3 causal: a closed unit only, never one still forming ⇒ repaint is inexpressible
I4 the grid is real (timeAt), never assumed uniform
I5 one clock per series in v1 — a clock of a clock is not expressible
I6 a leaf node mapped to a native kernel is byte-identical to it, warm-up included: a Flux indicator on a clock is the same citizen as a built-in
I7 the interpreter and the compiled WASM produce the same bytes, checked at every compilation

I2 deserves its own sentence, because it is the one an implementer gets wrong: anchoring a drawing wants the nearest bar; resampling an indicator wants the last closed one. Using the anchoring mapping for the resample silently reads the future.

#② Depth / z

The firewall here is not an argument — it is a property of the code. The overlay collector takes no 3-D parameter; only the host knows the camera angle, through a depth factor in [0,1] applied downstream by the shader. So an angle of zero is pixel-identical to plain 2-D, by construction rather than by care.

Flux emits a depth node (an ordinary analysis series) and the at z: binding; the host projects it and owns the window, the slider, the camera and the collapse. Every overlay instance pivots in z — line, band, cloud, profile — so 2-D and 3-D consume the same artifact.

at z: accepts any scalar and auto-normalizes it according to the source kind; a depth value, already a normalized fraction, shunts the normalization. Honest status: no kernel in the catalogue produces depth today, so the kind is theoretical in v1 while the z space is real — it is frozen now because retrofitting a coordinate is expensive.

#③ Representation descriptor

A chart type is an id, a class, six hooks, and one metadata member:

RepresentationDescriptor = {
  id, klass: 'A1' | 'A2' | 'B',
  transform(raw, params)  -> Series          // A1 = identity ; A2 = a same-length derived store ; B = re-binned COLUMNS
  reduce(bars, …)         -> aggregate       // the LOD decimator — breach #1
  renderPrimitive(frame)  -> elements        // authored as `render`
  updateLastUnit(el, …)   -> bool            // in-place mutation of the head unit
  liveReduce(state, tick) -> extend | append // A = in place ; B = extend a column, or reverse → append
  persistKey(unit)        -> key             // the non-lossy anchor — breach #2
  capabilities { … }                         // METADATA — the 7th member, not a hook
}

Representation hooks
Figure — the six hooks and where each one meets the host: the class is derived from the transform, not from the render primitive.

klass and capabilities are derived, never authored. The grammar admits the id and the six hooks; the compiler deduces the rest — exactly as pane and scale are deduced from a kind:

What the hooks do klass seriesKind persistence
the transform / clock re-bins price (an ordinal x, a non-injective time mapping) B follows the render primitive (column, or line for a polyline) its own slot
the transform derives a same-length store (a line, a Heikin-Ashi) A2 ohlc shared
the transform is the identity (a raw candle) A1 ohlc shared

The distinction is worth stating precisely because it is easy to get backwards: the class follows the RE-BIN, not the render primitive. A Kagi draws a polyline and a Renko draws a brick, yet both are class B — because both re-bin price. That is what routes them to the column-correct decimator and to their own persistence slot, rather than to the verbatim aggregation path a candle uses.

#④ Registries open to scripts

The three registries — indicators, drawings, representations — are already type-agnostic. Nothing in them tests a "is this a script?" flag. An entry written in Flux is indistinguishable from a built-in the moment it has the same shape:

Registry Entry shape
indicators { id, label, category, mode, defaults, params, series, compute } + a recursive/windowed/batch descriptor
drawings { barExtent, priceExtent, render, hitTest, + LOD }hitTest and the LOD are derived by the host from the render geometry, never authored
representations the descriptor above

What must be built is a dynamic registration mechanism — the tables are static literals frozen at boot. The recommended shape is a second table consulted after the built-in one, so the native hot path is not touched at all. This is an opening, not a new substrate.

#⑤ Transition descriptor

Cosmetic, and deliberately outside the registries. Today the morph is driven by an ad-hoc plan object; the contract reifies it into a named type — duration, easing, wave, stagger spread, wick lead, surplus policy, chrome fade, hold deadline, flip timing — plus per-call overrides (over D, stagger, surplus:) and a per-representation morph: hook.

The heavy per-candle morph stays native. Flux orchestrates it: it injects the plan once.

#How Flux compiles to these contracts

Nothing new is introduced under the language. Each construct lands on a seam that already exists:

Construct Compiles to
clock + @ a series producer; the @ node routes no-op / remap / sample, the expression itself computed by the native engine
depth, at z: an analysis node exposed as a series key, consumed by the z-source and the projector
representation an entry in the render-series table; morph fills the transition plan
an indicator a registry entry (label, params, series inferred from the inputs and the kinds) plus a recursive/windowed/batch descriptor, accepted with no flag — and therefore served exactly like a built-in, byte for byte

The hot path — the stepper kernels, the columnar aggregation, the candle renderer, the depth packing, the morph — stays native and byte-identical. Flux generates the artifacts the seams already consume.

#The two breaches

Two gaps in the host must close before any price-driven representation — script or native — can work. They were identified and costed independently of Flux; Flux merely rides on them.

Breach #1 — the per-type reduce hook (level of detail). The chart decimates bars for the zoom level by merging them, blind, through one columnar aggregator. For an OHLC series that is correct. For a re-binned column series it is wrong: merging by min/max collapses an alternation of up-columns and down-columns into one fat body with a false direction, off the grid. The fix is small and byte-safe: the bar store gains a kind; the aggregation call is gated on it; ohlc keeps the existing aggregator verbatim (zero pixels change), while column routes to the descriptor's own decimator — same signature, same return, a drop-in.

Breach #2 — persistence scoped by type. The drawings key is (asset, timeframe) with no discriminant, and the anchor is a raw timestamp. Both break for a re-binned representation: several columns can share a bar's time (the time mapping is non-injective, so a drawing lands on the wrong column), and one key mixes the drawings of two different chart types. The fix adds a representation discriminant to the key and routes anchoring through the persistKey hook — timestamps for class A (unchanged), a representation-stable anchor (price plus a box ordinal) for class B.

#The decisive test: Point & Figure as a script

The question that settles whether the architecture works is simple: can a fully price-driven chart type be written as a library script, with no change to the core? Point & Figure is the hardest case, so it is the one to answer.

fluxrepresentation pnf(box, rev) {
  transform:      rebin(close, box, rev)                  // price → X/O columns: a price clock
  render:         column{ at: (clock.index, lo..hi), glyph: if dir == 1 then X else O }
  reduce:         columnDecimate(bars, k)                 // the column-correct decimator (breach #1)
  liveReduce:     extendOrAppend(state, tick)             // extend the head column, or reverse → append
  updateLastUnit: mutateHead(el, unit)                    // mutate the head column in place
  persistKey:     (lo, clock.index)                       // a price + box-ordinal anchor (breach #2)
}

Each hook takes a value — an expression, a block, or a render primitive. (The bodies above are named for readability; a real implementation inlines them.)

Every piece types, and the type system forces the physics — the box must be a level, a displacement, because anchor + count * box only type-checks that way (see Kinds). The column state is an ordinary bounded scan:

flux// the column state: a record whose kind is  record{ dir: dir, extreme: price, count: num }
def column(box, rev) =
  scan({ dir: 1, extreme: close, count: 0 }, (p) -> advance(p, box, rev))

count is dimensionless, so count * box is a level and extreme + count * box is a price. Causality holds: the column advances on closed price, and a past column is frozen.

Piece Contract Status
pnf(box, rev) as a clock the re-bucketizer must be built (it depends on breach #1)
transform / render / updateLastUnit the shapes already exist in the host
klass: 'B', seriesKind: 'column', own-slot persistence derived from the hooks — the deduction must be built
reduce ③ + breach #1 the gate must be built
liveReduce + a length guard to build
persistKey ③ + breach #2 to build
the column state the lattice a pure script — the record kind makes the scan typable
optional depth: (z proportional to column volume) inherited for free — the projection is generic
the registry entry dynamic registration must be built

The honest tension. A single time bar can cross many boxes in a flash move, so cost per bar is not trivially constant. That is capped — maxBricksPerBar — and beyond the cap the host aggregates rather than blowing the budget. The cap is a design constant, not something the lattice can derive.

Point & Figure is therefore entirely expressible as a library script, with the two breaches as the only core modifications. Renko, Kagi, three-line-break and Range follow a fortiori — they are strictly simpler instances of the same class.

#Cross-series and multi-asset

The chart is multi-asset and multi-currency, so the language expresses cross-series work from the start, with no new grammar:

fluxbtc    = series("BTC-USD")
eth    = series("ETH-USD")
spread = btc.close / eth.close                                   // ratio — plottable
corr   = stat.correl(returns(btc.close), returns(eth.close), 30) // osc(-1,1)

#Reserved extension seams

Every future capability enters through one of two doors, which is what keeps the analysis core untouched:

The seams held open, with their honest status:

Seam Status
First-class input (input.key, input.pointer, edge events, a focus/ownership model) v1 covers pointer and touch; keyboard, pointer-lock and gamepad plug in without a rewrite
A retained scene with pluggable targets — one renderer for 2-D, chart and 3-D; a world3D space; declarative vetted 3-D primitives Reserved. v1 ships 2-D and the chart's 3-D; a general 3-D scene is post-v1
Parallelism Realized in v1 — the scheduler runs the pure graph; it adds no power to the core
Assets and kernels by handle (asset:load, a vetted-kernel escape hatch) designed
The capability namespace (input:*, gpu:*, net:*, data:source, app:launch, wallet:*, social:* …) extensible by the same mechanism
External data through consent (net:fetch), typed by a declared schema v1, client-side; a server proxy is Post-v1.
A third-party asset source (data:source) — registering a series producer Post-v1., vendor-verified; ingestion is causal and append-only, so no-repaint survives
Module visibility (private / package / pub) v1
An embeddable chart API that accepts Flux scripts as arguments, sandboxed v1
Chain and wallet (wallet:* / chain:*) — the script builds an intent, the wallet signs v1 for connect/read/simulate/send; contract calls are Post-v1.
Identity and social (social:* / present:*) — host-resolved, pairwise-opaque handles v1 for contacts, invite and the data channel; vendor-verified tier, never anonymous — the same tier as data:source and chain:send. A/V media is gated on the deferred capture consent

The principle behind the table: no seam ever adds power to the core. It adds an input stream, or an output target mediated by a capability. Games, live spreadsheets, a 3-D scene — all of them are special cases of those two doors.

#See also

↑ contents

Packages and distribution

A Flux library is distributed as a compiled, sandboxed artifact with a sealed manifest, pinned by the hash of its contents. Not by a version range. Not by a name that a registry resolves at install time. By the hash.

That one decision propagates into everything on this page: how the dependency diamond is dissolved rather than solved, why the build is reproducible, why a purchased library cannot smuggle a capability into your app, and why a script you shipped last year still runs, byte for byte, today.

#What a package is

Two notions are easy to confuse, so they are named apart:

a registry (indicators, representations, drawing tools) a runtime extension point — a script registers itself under an id, and the host serves it like a built-in
a package a versioned dependency artifact, pinned by hash, with an aggregated capability manifest — something you import

A package is named by a readable coordinate — author/package — and imported:

fluximport author/indicators as ind

plot ind.superSmoother(close, 20)     // its `pub` entries; everything else stays private

Only entries marked pub cross an import boundary. private and package visibility remain intra-script encapsulation, and are orthogonal to the package boundary.

#Content addressing dissolves the diamond

The coordinate author/package is a readable indirection. What is actually linked is a content hash.

So two versions of the same library are two distinct hashes that coexist, with no name conflict. The classic diamond — A depends on C@x, B depends on C@y, your app pulls in both A and B — is not resolved. It does not arise:

The dependency diamond, dissolved
Figure — two versions of one library are two linked units; there is no version to select, and therefore no conflict to resolve.

And the monomorphic type discipline makes that safe rather than merely possible: a record exported by C@x and one exported by C@y are two distinct monomorphic types. The seam between A and B can never pass one where the other is expected — that is [ErrField], at compile time, by inference. Not a warning. Not a convention.

The size cost of coexistence is recovered by common-subexpression elimination across the graph: two versions that share an identical sub-graph share it at the node level, whatever their names.

#Selecting a version, when a human is in the loop

The grammar of an import is exactly import author/package [as alias]. There is no version constraint in the source, and that is not an oversight — a source that carried a range would be a source whose meaning depended on what a registry answered that day.

Post-v1. A readable version layer above the coordinate — the place where a human states "at least 1.2" and a tool turns that into a hash — is designed, and it is an optional overlay on the naming layer, never a production of the language. Where it applies, the resolution is minimal version selection: take the lowest version satisfying every constraint, then pin its content hash, and write the hash into the lock.

Why the lowest, and why not a solver. Minimal version selection is deterministic by construction — no solver, no search, no "resolution changed because the registry did". The build becomes a pure function of the constraint set. The alternative — "the newest compatible version floats underneath you" — would break byte-identity and server-side replay, because two builds of the same source would link different code.

The readable version lives on the naming layer, and it is a convenience for the human who chooses. Underneath the artifact, exactness is the hash — and the hash is what the source, the lock and the server all speak.

#The lockfile is the build hash

An application resolves its graph once, into a set of content hashes — the transitive closure — plus the pinned compiler version and the pinned routines. That set is the build hash.

Which is what makes this sentence definable, and checkable: the same dependency graph produces the same bytes. Byte-identity between the two engines and server-side replay both re-link the exact closure the lock pinned — never a "compatible version" chosen at link time, which would desynchronize client and server.

The manifest is where that closure becomes inspectable. Four of its fields are inputs to the build hash, which is another way of saying that changing any of them produces a different artifact with a different name:

Pinned in the manifest Why it is part of the identity
the module hash the sealed bytes — what a server re-executes, and what a verifier recomputes
the toolchain — compiler version, and the pinned optimizer backend the same source through a different compiler is different bytes. Ignoring it would serve a cached module that no recompilation would ever produce again
the pinned routines — the hash of the deterministic maths library itself the module was gated against that library. A consumer holding a different one is running code nobody verified
the declared memory — pages, state cells, capacity checked against the module before instantiating it, so a footprint is a contract rather than a surprise

Why the maths library is in the hash and not merely "recommended". It is the subtlest of the four, and the one a normal packaging system would have missed. A pack's bytes are proved byte-identical to the interpreter's evaluation against a specific implementation of the transcendental functions. Link the same module against a different one — a bug fixed, a rounding tightened, a genuine improvement — and the proof no longer covers it. So a consumer whose maths library does not match the one in the manifest refuses to run the pack, and re-fetches. Not a warning, not a compatibility shim: a refusal. The drift this closes is exactly the drift nobody would notice, because the numbers would still look right.

#Linking

A purchased dependency cannot be compile-inlined: its source is never shipped — you do not inline what you are not allowed to receive. So a third-party library is a separate, signed WebAssembly module, linked by module imports. And that is the rule for every dependency, not only the purchased one: an open dependency ships its source, but it is still linked as its own module, so that its provenance, its trust tier and its hash stay its own rather than dissolving into yours. Two things come with that:

Every app pins the exact hash of every dependency, so an "update" produces a new app hash — never a silent drift underneath a frozen app. Mutable shared third-party modules are forbidden, because they would break byte-identity and replay at the root.

#Capabilities aggregate — and cannot escalate

This is the security property that makes a marketplace tolerable:

manifest(A) = ( ⋃ emit Cap over the transitive closure of A ) ⊓ the user's grant

Three consequences, all normative:

  1. A transitive dependency's appetite is visible. If a library three levels down wants the network, that request surfaces in your app's manifest, and the person installing your app sees it before they install. There is no hidden capability, and the confused-deputy attack is closed at the root.
  2. No dependency can exceed what the user granted. Authority flows only along import edges, capped by the grant.
  3. A dependency holds no capability object at all — so it can neither re-delegate one nor amplify one.

Non-escalation is structural: it is recomputed at compile time and pinned into the app's hash.

#The artifact

A distributed package is a fluxpack: an archive holding the compiled module, the sealed manifest, the compiled metadata a consumer needs in order to attach the module without a compiler — and, when the author distributes it openly, the source it was compiled from.

Entry What it is
the manifest canonical JSON — the sealed capability list, the provenance, the parameter schema, the declared presentation
the compiled module the WebAssembly the consumer actually runs
the compiled metadata sink layout, column offsets, series names — so a consumer attaches the module without inferring anything
the source optional, and the only transparent thing in the archive. Present ⇒ the pack is verifiable
documentation, an icon, a signature optional; the icon is hard-sanitized at load, because a pack is untrusted input

The compiled intermediate representation is never shipped, in any class of pack. A verifier that wants to check the module does not read an IR the author supplied — it re-derives the IR from the source and recompiles. Shipping an IR would mean trusting it.

What a fluxpack contains
Figure — the artifact carries everything a consumer needs to decide, and nothing a consumer must trust.

#Three distribution classes, and open is the default

Whether the source travels is a declared property of the pack, and it is the first field a consumer reads:

Class The source What the consumer can do
open — the default shipped, in the archive recompile it locally and check the module against it, byte for byte. Identity is checkable, not promised
closed not shipped run it in the sandbox, and inspect the sealed manifest — but never re-derive the module
licensed not shipped, and the module is encrypted and key-gated the same, under a licence the host enforces

That default is load-bearing, and it is the opposite of the usual one. A package's honesty about what it computes is checkable unless its author opts out — and opting out is visible in the manifest, before installation, next to the capability list. A consumer who is handed a closed pack knows exactly what they have given up.

Why a closed pack is still safe to run. Verifiability and safety are two different properties, and it is worth refusing to conflate them. The sandbox is the safety: a pack is a pure function over numbers, with no clock, no network, no I/O and no way to grow its own memory. The worst a malicious pack can do is compute wrong numbers — a bad signal, which the capability model and the sanitizer contain, and which no amount of source-reading would have caught either. Verifiability is a different guarantee: not "this cannot hurt me" but "this is what it says it is". open gives you both. closed gives you the first, and says so.

#The archive is deterministic, and the hash is the name

A package is content-addressed by the hash of its archive bytes, so the archive itself must be reproducible or the name is not stable. An ordinary zip is not: entry order, timestamps, permission bits and compression all vary. This one is constrained until it is:

Storing rather than compressing costs almost nothing — the wire is compressed by the transport anyway, and a pack is kilobytes — and it closes the decompression-bomb surface by construction rather than with a limit somebody has to get right.

A pack is untrusted input. It is verified before it runs: the structure, the manifest, the provenance, the declared limits — and the declared memory footprint is checked against the module before instantiation, so the footprint is a contract rather than a surprise. And the rebuild gate closes the last gap — the same source and the same lock, recompiled twice on different machines with different thread counts, must produce a byte-identical module. A non-reproducible build would break server-side replay silently, because a value-level oracle cannot see the bytes emitted across two compilations.

Licence compatibility is computed on the closure at publication and can refuse a publication (a paid closed artifact built on a copyleft dependency, for instance) — surfaced in the same inspect-before-install panel as the manifest.

The compute runtime is retained forever, append-only. The shared module of native kernels is linked by hash like any other dependency, so evolving a kernel produces a new hash and re-links only on republication — never a drift under a frozen app. An app bought years ago pins its runtime and stays verifiable; a build whose runtime reaches end-of-life is marked locally scored, never silently invalidated.

#What is deliberately excluded

Excluded Why
nested duplicate installs of the same library it fights byte-determinism; content addressing replaces it
constraint solving not bit-reproducible — minimal version selection or a hash instead
dynamic loading of untrusted source eval and friends are forbidden; every "load" is a host-mediated instantiation of a pre-vetted module
generics across module boundaries exports are monomorphic in v1 — which is exactly what makes the diamond safe
feature flags / conditional compilation across modules they would change the bytes; only the choice of dependency may do that

Post-v1. The public registry is a rollout, not a mechanism: the packaging, the pinning, the aggregation and the verification are all built. What is deferred is deploying the place where strangers publish to strangers.

#See also

↑ contents

Cookbook

Working recipes, ordered from the first line you will write to the last. Every one is complete — paste it and it runs.

A convention used throughout: a line marked // ✗ is a rejected example. It is there because knowing what the language refuses, and why, teaches more than another thing that works.

#Analytics

#An indicator, and everything it infers

fluxplot rsi(close, input(14))

Own pane, fixed 0–100 scale, midline, 30/70 guides, a parameter control. All from the kind.

#A band, and a fill

fluxbb = bollinger(close, 20, 2)
plot bb.upper, bb.middle, bb.lower
fill bb.upper..bb.lower

// ✗ fill bb.upper..rsi(close, 14)   — [ErrDim]: a price and an oscillator do not bound a region

#A histogram with a sign-driven colour

fluxm = macd(close)
plot m.hist { style: histogram, color: if m.hist > 0 then up else down }
plot m.macd, m.signal

#Your own function

fluxdef zscore(x, n = 20) = (x - sma(x, n)) / stdev(x, n)

plot zscore(close)            // (price − price) ÷ level → ratio

#A ribbon

fluxrepeat 8 as i {
  plot ema(close, 10 + i * 10) { color: mix(down, up, i / 7) }
}

#A divergence

fluxdef bearDiv(n) =
  let ph  = pivot_high(close, n, n) in           // (source, left, right) — confirms n bars later
  let px  = valuewhen(ph, close[n]) in           // this pivot's price
  let osc = valuewhen(ph, rsi(close, 14)[n]) in  // and its RSI
  px > valuewhen(ph, px[1]) and osc < valuewhen(ph, osc[1])

mark bearDiv(5) "bearish divergence"

A higher high in price against a lower high in RSI — which means the recipe has to reach one pivot back, and that is the part worth stealing. valuewhen(ph, px[1]): at the bar where a pivot confirms, px has just taken this pivot's value, so px[1] still holds the previous one — sampling it exactly there, and holding it, is how you compare two successive pivots.

valuewhen has no occurrence argument, and ph[1] is not a substitute for one: it delays the signal by a bar, not by a pivot.

Pivots are confirmed pivots — they carry a lag, which is why the price of the pivot is close[n] and not close, and they are final once emitted. A pivot that mutated until confirmation would be a repaint, and there is no way to write one.

#Signals, marks and alerts

fluxcross = close cross_up ema(close, 50)

mark  cross "crossed at {fmt.price(close)}"
alert cross "EMA-50 crossed up"
assert rsi(close, 14) <= 100 "rsi is bounded"     // a self-check; `na` during warm-up passes

A multi-condition setup reads as one expression, because that is what it is:

fluxsetup = close > ema(close, 200)
    and rsi(close, 14) < 35
    and volume > sma(volume, 20) * 1.5
    and in_session("09:30-16:00 America/New_York")

mark setup { shape: triangle }

// ✗ mark setup { shape: triangle, color: up }   — [ErrArg]: `color:` is a `plot` channel; a mark has none

#More than one clock

flux// paint the bars by the daily trend, on whatever chart you are looking at
color bars: if close > ema(close, 200) @ tf("1d") then up else down

// a higher-timeframe oscillator, shown here
plot rsi(close, 14) @ tf("4h")

// a miniature of the daily series, in the corner
sparkline close @ tf("1d")

Each of these reads the last closed unit of the coarser clock. The value a bar showed yesterday is the value it shows today.

#Cross-series

fluxbtc = series("BTC-USD")
eth = series("ETH-USD")

plot btc.close / eth.close                                       // ratio — relative strength
plot stat.correl(returns(btc.close), returns(eth.close), 30)     // osc(-1,1)

// ✗ plot btc.close + eth.close     — [ErrDim]: different bases
// ✗ plot btc.close + series("BTC-EUR").close   — [ErrDim]: a dollar is not a euro

#State

flux// a stop that ratchets and never loosens
def trail(mult) =
  let stop = close - mult * atr(14) in
  scan(stop, (prev) -> math.max(prev, stop))

plot trail(3)

A mode, as a variant and a match:

fluxvariant Trend { Up | Down }

def step(p, n) = match p.dir {
  Up   -> if close < p.ref - atr(n) then { dir: Trend.Down, ref: close } else p
  Down -> if close > p.ref + atr(n) then { dir: Trend.Up,   ref: close } else p
}

def flip(n) = scan({ dir: Trend.Up, ref: close }, (p) -> step(p, n))

color bars: match flip(14).dir { Up -> up ; Down -> down }

Note the def step pulled out of the scan(…) call: a match written inside a call's parentheses needs explicit separators between its arms, and lifting it out is the readable fix.

#Canvas

#A comet

fluxcircle { at: (bar.i, spring(close)), r: 6, glow: 16, trail: 24 }

#Fireworks on a breakout

fluxon close cross_up highest(close, 250)[1] -> burst(40) ring { r: 6 -> 24, opacity: 100% -> 0%, life: 2s }

#A heartbeat

fluxon every(1 bar) -> spawn ring { at: (bar.i, close), r: 4 -> 20, opacity: 80% -> 0%, life: 900ms }

#A trend aurora

fluxbackdrop { fill: mix(down, up, norm(ema(close, 50) - ema(close, 200))) }

#A session highlight

fluxbackdrop { fill: token.grid, opacity: 8% } when in_session("09:30-16:00 America/New_York")

#Auto support and resistance

fluxgroup {
  line { at: (bar.i, valuewhen(pivot_high(close, 5, 5), close[5])), w: screen.w, stroke: down }
  line { at: (bar.i, valuewhen(pivot_low(close, 5, 5),  close[5])), w: screen.w, stroke: up }
}

The last confirmed swing high and the last confirmed swing low, each held until the next pivot replaces it.

Why not the last four pivots? Because a window is a window over bars, not over pivots: window(valuewhen(ph, close), 4) takes four bar-samples of a step-held series, and between two pivots that is the same level, four times over. A sparse series — N pivots, however far apart they fall — is not a window at all. It is a representation with a declared maxPivots, the same bounded pattern as Point & Figure further down this page. An unbounded list of pivots is not something you can ask for, and that is the totality rule doing its job.

#Transitions

fluxon switch(asset) -> morph chart over 500ms { ease: inOutCubic ; stagger: 0.3 ; surplus: collapse }
on click        -> focus(view, at: (bar.i, close), zoom: 2.0, over: 600ms, ease: outBack(1.2))
replay from close cross_up ema(close, 200) over 8s

replay from takes a signal, not a bar: you replay from the moment something became true, and the engine finds the bar. A replay anchored to an ordinal would mean something different on every chart it ran on.

#The forming bar

fluxplot ema(live(close), 20)                  // ✓ display only — updates within the forming bar
// ✗ alert ema(live(close), 20) > 100      — [ErrFirewall]: a decision may not read a forming value
// ✗ plot rsi(live(close), 14)             — [ErrFirewall]: analysis may not consume it either

The first line is flagged non-replayable in the guarantees panel — visibly, at the moment you make the trade.

#Money and exactness

fluxqty    = 3d                                  // decimal(scale 0) — the glued `d` makes it exact
px     = 41.25d                              // decimal(scale 2)
gross  = qty * px                            // `×` sums the scales → decimal(scale 2)
fee    = decimal.round(gross * 0.001d, 2)    // to 2 decimals, half-even, deterministic

// ✗ plot toFloat(fee) + fee                 — [ErrRepr]: an f64 and a decimal do not mix

Rounding is not a mode you pick: decimal.round is half-even and pinned, one routine shared by the interpreter, the compiled module and the server — because a rounding that differed by engine would put two machines a cent apart, and byte-determinism would be a word.

#Text

fluxsym = "BTC-USD"
mark close cross_up ema(close, 50) "{sym} {fmt.price(close)} ({fmt.pct(change(close, 1) / close[1])})"

A message slot takes a string literal, never a binding that happens to hold one. The label is therefore written where it is read.

Interpolation is a formatter call, and the formatter is pinned — so the label reads the same on every engine.

#Calendar

fluxexpiry = time + time.months(3)      // period — calendar, DST-aware
cutoff = time + 86400s              // duration — exactly 24 hours, DST or not

// ✗ plot time.days(1) + 86400s      — [ErrRepr]: a calendar span and a machine span do not add

"One day" and "24 hours" are different things twice a year, and the type system knows which one you meant. A period is built from time.* and resolved against a calendar; a duration is a literal carrying an s or ms suffix, and is exactly as long as it says.

#An application

fluxvariant Msg { Tick | Reset | Got(v: num) }

app watch {
  capabilities: [ clock, chart:read ]

  init(p)        = { n: 0, last: na }
  update(m, msg) = match msg {
                     Tick   -> { model: m with { n: m.n + 1 }, cmds: [] }
                     Reset  -> { model: m with { n: 0 },       cmds: [ PlaySfx("reset") ] }
                     Got(v) -> { model: m with { last: v },    cmds: [] }
                   }
  view(m)        = row {
                     text("ticks: {m.n}")
                     text("rsi: {fmt.num(m.last)}")
                     button("reset", Reset)
                   }
  subs(m)        = [ OnTick(1000, Tick), OnSeries("rsi", Got).throttle(200) ]
}

Everything ambient arrives as a message; every effect leaves as data. Which is why an application is tested at four grainsstep (one update), trace (a fold over a literal list of messages), view (a snapshot of the view tree) and property (an invariant asserted over a generated trace) — all of them assertions over pure functions, with no mock anywhere. The first two:

fluxassert update({ n: 0, last: na }, Tick) == { model: { n: 1, last: na }, cmds: [] }

That is the step grain. The trace grain folds a literal list of messages through the same updateassert fold(init(p), [ Tick, Tick, Reset ]) == { n: 0, last: na } — and the literal list is the whole point: it is the mock. There is no Sub to stub, no clock to fake, no network to intercept, because none of them ever reach update. They only ever produced messages, and a message is a value you can type out by hand.

#A chart type, as a script

fluxrepresentation pnf(box, rev) {
  transform:      rebin(close, box, rev)   // re-bin price into X/O columns
  render:         column { at: (clock.index, lo..hi), glyph: if dir == 1 then X else O }
  reduce:         merge(cols)              // the column-correct decimator
  liveReduce:     last(cols)               // extend the head column, or reverse → append
  updateLastUnit: patch(cols)              // mutate the head column in place
  persistKey:     "pnf-v1"                 // a price + box-ordinal anchor
}

All six hooks are mandatory and carry a value — there is no eliding one behind a comment. A representation that could not say how it decimates, or how it extends its head unit live, would not be a chart type; it would be a chart type's first half.

The type system forces the physics: box must be a level (a displacement), because anchor + count * box only types that way. Model the box as a price and the compiler refuses — which is the moment you learn something about Point & Figure.

#Things that do not compile, and why

fluxclose + rsi(close, 14)        // ✗ [ErrDim]      — a point plus a dimensionless number
close[-1]                     // ✗                — there is no negative index; the future has no syntax
window(close, len)            // ✗ [ErrTotal]     — a window bound must be a constant
close @ renko(50) @ tf("1d")  // ✗                — one clock per series in v1
w.filter((x) -> x > 0)        // ✗                — a data-dependent length would break totality;
                              //                    use `vec.where(w, (x) -> x > 0)` — same length, `na` where false
match dir { 1 -> up }         // ✗                — `dir` is a scalar; discriminate it with `==`

Each of these is a design decision you can read about, not a limitation you have to work around blindly: kinds, time and state, the four planes.

#See also

↑ contents

Guarantees

This page is for the reader who has to decide whether to trust Flux with something that matters. It states each guarantee in one sentence, says exactly what it does and does not cover, and names the machine check that enforces it — because a guarantee whose only enforcement is a promise in a document is not a guarantee.

Nothing here is aspirational. Where a limit is real, it is stated as a limit.

#The seven guarantees

Guarantee In one sentence Enforced by
Totality Every program terminates, and its cost per step is known before it runs. Const-folded bounds on every window, loop and collection; a graph-size budget; [ErrTotal] at compile time
Causality (no-repaint) A value, once produced for a step, can never change. Past-only delays; closed-unit resampling; every feedback cycle crosses a unit delay; [ErrCausal]
Byte-determinism The same program on the same data produces the same bytes, on every engine and every machine. Pinned routines; a fixed reduction order; canonical na; the I7 gate at every compilation
Dimensional soundness Meaningless arithmetic does not compile. The kind lattice and the operator algebra, enumerated and machine-checked per family
The firewall Presentation may read analysis; analysis may never read presentation. A static dependency check; [ErrFirewall]
Capability security A script has no ambient authority; every effect is inert data the host executes under a granted capability. Compile-time rejection of an ungranted request ([ErrCapDenied]); a model-checked capability monitor
Verified optimization The optimizer cannot ship a wrong value. Translation validation against the unoptimized graph, at every compilation

#What each one actually means

#Totality

Every window, every loop, every collection carries a constant bound, under a cap. A program that cannot state its bound does not compile.

What it covers. Termination, and a cost per step that is computable at compile time. There is no per-bar timeout, because there is nothing to time out: an over-budget program is rejected, not killed.

And the verdict itself is deterministic. Accept or reject is a pure function of the source, decided by counters alone — never by a clock. The editor's build timeout (on the order of two seconds) is an interactive cancellation, a matter of keeping the UI responsive; it is never a verdict.

Why this rule exists. A wall-clock verdict would be machine-dependent — the same script accepted on a fast machine and rejected on a slow one. Two users would then not be running the same language, and replay, which assumes that what compiled there compiles here, would break; anti-cheat would break with it. Determinism has to start at the compiler's answer, or it does not hold anywhere downstream.

What it does not cover. It does not make your algorithm fast. It makes its cost knowable.

#Causality — "no-repaint"

Delays reach backwards only. A resample reads the last closed unit of a coarser clock, never the one still forming. Every feedback cycle must cross a unit delay.

What it covers. The value a bar showed yesterday is the value it shows today. Live and historical evaluation produce the same bytes. Repaint is not discouraged — it is inexpressible: there is no syntax for a negative index, and no name for the forming unit inside analysis.

The one exception, and its wall. live(e) reads the forming bar, and it may flow only to display sinks. Feeding it into an alert, an assertion or a calculation is [ErrFirewall]. A script that uses it is flagged non-replayable, visibly, in the guarantees panel.

#Byte-determinism

Scalar f64. No SIMD in the deterministic domain. No floating-point reassociation. Every transcendental, every decimal operation, every Unicode fold, every calendar addition, every random draw, and every sort over absent values goes through one pinned routine, shared by the interpreter, the compiled module and the server.

What it covers. Two engines agree bit for bit. A golden holds. Replay reconstructs a model exactly.

Post-v1. Server re-execution — a server re-running a client's work to catch a forged result — rests on exactly this determinism, and is designed. But in v1 the native/server leg is verified client-side: re-execution on the server follows the server port of the grader, and is deferred.

What it does not cover. Presentation. The GPU, the compositor, unseeded randomness and wall-clock time are outside the oracle by design — and the firewall guarantees they never enter it.

#Dimensional soundness

A price is not a volume; a BTC price is not an ETH price; an exact decimal is not a float. Adding them is a compile error, not a runtime surprise and not a silently wrong number.

What it covers. A whole class of bugs that other systems find in production, if at all.

What it does not cover. It is not a proof system. An osc(0,100) bound is a presentation claim, not a runtime invariant — only clamp makes a bound real. Flux deliberately has no solver, and says so.

#The firewall

Four things may never reach analysis: screen space, the wall clock, unseeded randomness, and the forming bar. All four raise [ErrFirewall].

What it covers. A stranger's animated, random, interactive scene can run next to the number your decision rests on, and cannot touch it. This is what makes user-generated content a routine act rather than a risk assessment.

#Capability security

A script holds no capability object. It emits a request; the host, the only holder of the resource, executes it — and only if the manifest declared it and the user granted it.

What it covers. No ambient authority. No token in the script. No re-delegation. A transitive manifest that surfaces a dependency's appetite for the network before install, capped by the user's grant. A revocation mid-session is journaled, so a re-fold reproduces it and commands issued after it fail closed.

The honest limit. The language is safe by construction; the capability monitor and the view sanitizer are ordinary code, and they are the residual attack surface. That is precisely why they are the one component that earns a model check, and why the view primitives are a closed, typed set rather than a string.

#Verified optimization

The reference semantics of a program is the evaluation of its unoptimized graph. Every compilation checks the optimized module against it, bit for bit, on hostile data.

What it covers. A miscompilation cannot ship. If the optimizer diverges, the compile serves the unoptimized path and raises a diagnostic that turns the test suite red.

The honest limit. The gate proves equality over the corpus's value coverage and up to a sweep ceiling of periods. A rule whose divergence only appears beyond that ceiling would pass — which is why rules touching kernels or state carry an explicit proof obligation at the real maximum period.

#The verification harness

The guarantees are checked by a suite whose sub-suites each declare an oracle and a corpus:

Suite Asserts Blocking
Goldens every example is a deterministic golden; an unchanged golden stays byte-identical yes
Properties principality; confluence (the kind is invariant under any topological order); incremental re-typing ≡ full inference; the memory plan is a deterministic function of the graph yes
Fuzz + a well-typed generator the parser and the type checker are total (any input yields one tree or a clean rejection); the generator emits type-correct causal graphs that feed the oracle yes, once it feeds the oracle
Differential oracle interpreter ↔ WASM ↔ native kernel — covering I6, optimized ≡ reference, and I7 yes
Metamorphic the enumerated semantics-preserving relations: optimized ≡ reference · interpreter ≡ WASM ≡ server · 1 ≡ N workers · peak-plan ≡ sum-plan · confluence under any topological order · recompile ≡ recompile, byte-identical · the absolute draw-list is invariant to target and sequence yes
Stress 1 ≡ N the same graph under one worker and under many, adversarially assigned: identical bytes, zero concurrent slot writes yes
Lattice enumeration the laws and every admissibility judgment, enumerated per family yes
Capability monitor "no command outside the manifest is ever executed" yes
Bench the cost model is calibrated by measurement; the runtime peak equals the planned peak advisory

One subtlety is worth knowing about, because it is the kind of thing a careful reader asks: the three-way oracle calls the same pinned routine on all three sides, so it is blind to a bug inside a pinned routine. Each pinned routine therefore carries a second, independent reference implementation, compared bit for bit on fuzzed input. The oracle catches disagreement; only the second implementation catches a shared mistake.

#Reproducible builds

The build hash is a pure function of the source, the dependency closure, the compiler version, the pinned routines and the canonical memory plan. The rebuild gate recompiles the same inputs twice — on different machines, with different thread counts — and asserts the emitted module is byte-identical.

Server-side replay depends on this. A non-reproducible build would break replay silently, because a value-level oracle cannot see the bytes emitted across two compilations.

#The guarantees panel

After a compile, the editor states what your program actually earned:

✓ No-repaint     ✓ No look-ahead     ✓ Deterministic
✓ Bounded memory ✓ Byte-identical    ⚠ contains live() → non-replayable

It is not decoration. A guarantee you traded away should be visible at the moment you traded it.

#What is not guaranteed

Stated plainly, because a trust page that only lists strengths is a sales page:

#See also

↑ contents

The editor

An editor for Flux can do things an editor for a general-purpose language cannot — not because more effort went into it, but because the language hands it more to work with. Every value has a kind, every program is a graph, and every evaluation is deterministic. So the editor can filter a completion list by dimension, show you the value of a binding at the bar under your cursor, and tell you why a signal is true — without running anything twice.

This page describes what the tooling does and, more usefully, why it can.

#Code intelligence

#Completion, filtered by kind

After a ., you get the members of the record. After a stream, you get only the functions whose first parameter accepts that kind:

close.          // → ema, sma, rsi, highest, … (everything that accepts a `price`)
rsi(close,14).  // → ema, sma, change, … (kind-preserving families) — but NOT `vwap`

The exclusions are the half worth reading. vwap wants a signal, so it is never offered after a price. Neither is atr — for a different reason: it reads high, low and close itself and takes only a length, so it has no source parameter for close. to fill. close.atr(14) is [ErrArg], and a list that offered it would be handing you a line that does not compile.

That is the payoff of method-style chaining: it turns the type system into a discovery mechanism. You do not need to know the catalogue; you need to know what you have.

And at the head of an empty line, the editor offers the output verbs (plot, def, let, mark, alert) — so a newcomer discovers that plot is how a value reaches the screen, instead of having to know it in advance.

#Signature help and hover

Typing ema( opens ema(source: price, length: lit) → price, with the current argument highlighted.

A hover on an operation gives its documentation, its kind signature, a miniature example, and a live sparkline of that operation on the data currently on screen. A hover on a binding gives its inferred kind and its value at the bar under the cursor.

That last one is worth pausing on: it is possible because evaluation is deterministic and the graph is already computed. There is no "debug build", and nothing is re-run.

#Diagnostics that teach

price + osc — you are adding a price and a 0–100 oscillator.
  close + rsi(close, 14)
          ^^^^^^^^^^^^^^ osc(0,100), a dimensionless bounded value
  Did you mean  close + atr(14)  (a price + a displacement)?

Three kinds of help sit behind that:

#Inlay hints

h = macd(close).hist                ⟦level · −12.3⟧
m = ema(close, 20) @ tf("1h")       ⟦price · @1h⟧

The ⟦…⟧ is not text in your file — it is rendered beside it.

The kind, the value at the cursor bar, and — when a binding runs on a non-default clock — its clock provenance. You can see that a value comes from the hourly series, without the language having to encode the timeframe in the type (which would break the confluence idiom the whole design is built to allow).

#The novice register

Warnings and style lints are deferred until your first green compile, then revealed opt-in. Day one never shows a wall of nags. The hard/soft classification of the error channel is unchanged — this is a presentation policy, not a semantic one.

#The canonical formatter

Format on save, no options to argue about — and one specific job beyond tidiness: it neutralizes the significant-newline trap. It normalizes line breaks and continuation indentation so the extent of every statement is visible. A newcomer never has to guess where an expression ended.

#Semantic colouring by kind

Prices, oscillators, signals and canvas primitives are coloured differently from one another — not by syntactic category, but by what they are. It is a small thing that turns out to matter: you see the shape of a program's dimensions before you read it.

#Live preview

The editor compiles on idle (a short debounce) and applies the result to the chart. When there is an error, it does not blank the preview: it evaluates the typable cone — the largest part of the graph whose every input is free of errors — and renders the rest as . The last valid version is a fallback only if the cone is empty.

The consequence is that a half-typed name costs you one value, never the screen.

The status chip then tells you what your program earned:

✓ No-repaint   ✓ No look-ahead   ✓ Deterministic   ✓ Bounded memory   ✓ Byte-identical
⚠ contains live() → non-replayable

Post-v1. For an application, hot reload generalizes: the host re-folds the retained message journal with the new update, without calling init — so your application's state survives an edit. A change of shape falls back to the migration path; it is never a silent reset.

#Debugging a graph

A Flux program has no call stack, so an imperative debugger would be answering a question nobody asked. What a program has is a graph of typed signals over an ordinal axis — so the cursor is two-dimensional: when (which bar) × what (which node).

Movement What it is
Ambient values the inlay hints already show every binding's value at the cursor bar. There is no mode to enter.
The chart is the scrubber a playhead on the chart is the bar cursor — drag it, or use the arrow keys. No separate timeline.
The probe scrub to a bar and read the table of every binding's value there. Deterministic, so it is exact rather than sampled.
The dataflow view the compiled graph, rendered as a graph — because that is what it is. Click a node: the source highlights and its series appears.
The causal cone "why is this signal true here?" — highlight everything the value at this bar actually depended on.
Data breakpoints not a line breakpoint but a data one: "go to the first bar where macd cross_up 0". The series is already computed, so the jump is a search, not a re-run — it is instant. Likewise: the next event, the next na, the next divergence.
Time travel replay is exact, so stepping backwards is not an approximation; it is the same computation.

Post-v1. For an application, these same movements transpose to an (event, field) cursor over the message journal: the data breakpoint becomes "the first message where score crosses 40", time travel becomes reverse-step along the journal, and the trust lens becomes a diff of the model against a reference run. The bar-axis debugger above is v1; its application twin is the deferred half.

#Sliders, and the parameter model

input(14, 2..200) renders a slider in the gutter. What happens when you drag it is the part worth knowing:

The tuned value lives in a parameter overlay, not in the source literal. The source carries the default. Dragging updates a parameter and re-runs the incremental step — it does not recompile, does not mutate your source, and does not flood your undo history.

The unit that is persisted, shared and replayed is therefore (source hash, parameters) — one compiled module, many tunings, with byte-identity on (artifact, parameters) → output. "Bake default" is an explicit action, and the only path that writes an overlay value back into the source.

A parameter with a declared range is bounded by its maximum: memory is sized for the worst case, so dragging a slider can never allocate.

#The performance HUD

Per script: the node count, the cost per bar, the canvas frame budget — and a warning when a script is heavy. The cost gutter reads the optimized graph, so what you see is what you pay.

#Doc-as-data

The hover card, the completion list, this documentation, the error messages and the snippets all render from one structured record per function, kind, keyword and operator. They cannot drift, because they are the same data.

And a completeness lint keeps it honest: every construct in the language has a documentation record and at least one runnable example — and every example is a golden. A documented function whose example stops working turns a test red, which is the only kind of documentation guarantee worth having.

#See also

↑ contents

FAQ

Most questions about a total, causal language turn out to be already answered by the model — the guarantee is in the design, but it is not visible at first glance. This page makes those answers explicit, and points at the section that is normative for each.

The second half is the opposite: things Flux deliberately does not do, and what it offers instead.

#"How does Flux handle…?"

#Multiple timeframes

@ is a causal resample: ema(close, 20) @ tf("1h") reads the last closed hourly unit. clock is a first-class kind — composable, storable, passable through an input — and @ is its eliminator. One clock per series in v1, which is what keeps the dangerous mixtures out; and the confluence idiom (close > ema(close, 50) @ "1d") is the goal, not an error to forbid.

Time and state

#Intra-bar values

live(e) reads the bar in formation, per frame, and it flows only to display sinks. Feeding it into a decision — an alert, an assertion, a calculation — is [ErrFirewall]. That wall is no-repaint: you may look at a provisional value, and you may not act on it as if it were final.

The four planes

#Types

rsi really does have kind osc(0,100). price and osc really are incompatible at compile time. Bounds really do propagate (rsi − 50 → osc(-50,50)). And there is no solver — the lattice is finite by family, so the laws are checked by enumeration rather than proved by machinery.

Kinds

#Bit-for-bit determinism

Yes — across the interpreter, the compiled module, and the server, on ARM and on x86. Scalar f64, no SIMD in the deterministic domain, no floating-point reassociation, a pinned reduction order, and one pinned routine for every operation where two implementations could disagree (transcendentals, decimals, Unicode, the calendar, randomness, sorting with absent values, and the bit pattern of na itself).

Compiler and runtime

#Persistent state, reducers, state machines

scan(seed, (prev) -> …) advances state one step per bar. Composite state is a record; a state machine is a variant plus a match (exhaustive, or it does not compile); bounded iteration is loop(max, …).

Time and state

#External data (funding, open interest, macro)

It enters the APP plane through typed subscriptions — never analysis directly, because the firewall forbids an application from writing an indicator. To become an indicator, a stream must be folded into closed bars and handed over through the data:source seam, which the host ingests append-only: a closed bar is never revised, so no-repaint survives.

Reserved. The v1 limit is named rather than hidden: an indicator driven by a non-price series is not expressible until the metric kind is armed. That seam is designed, and inert.

net · compute

#Effects and "worlds"

There is no effect annotation, because there are planes. ANALYSIS / CANVAS / TRANSITION / APP, with a one-way firewall checked statically. And you never declare which plane you are on — it is inferred from what you write.

The four planes

#Tooling: why is this signal true here?

Global common-subexpression elimination, a dataflow view with a causal cone, an optimization report, a performance budget, reversible time along the bar axis — step, scrub and jump, in both directions, because replay is exact — and assert with goldens. The debugger's question is not "what is the value" but "why", and the graph can answer it.

The editor

#"Why does Flux not…?"

#…tag the timeframe in the type (series<H1, price>)?

The safety that would buy — a visible timeframe, an explicit conversion — is already provided by @ plus the one-clock-per-series rule. And the tag would reopen the lattice with a new axis, and break the confluence idiom: close > ema(close,50) @ "1d" is the thing people actually want, not a mistake to prevent. Time is first-class here through the clock kind and the @ operator — not through a type parameter.

#…support tick-by-tick and order-flow scripting?

Post-v1. Bar-centric in v1, and said out loud: the whole live infrastructure is bar-streaming, and live() operates on the forming bar, not on ticks. Sub-bar granularity is a named extension — a scope boundary drawn on purpose, with a mechanism already behind it, not an oversight.

#…prove relational invariants (high ≥ low)?

That needs a solver, and Flux explicitly has none. Kind bounds are presentation claims, not runtime invariants (only clamp makes a bound real). Refusing the solver is what keeps the type system decidable, fast, and honest about what it checks — which is precisely the trap that the people asking for invariants are usually trying to avoid.

#…add sign refinements (price ≥ 0)?

A new axis with a weak payoff, contradicting "bounds are claims" — and wrong on the data anyway: a signed volume is wanted (that is how a cumulative volume indicator works), and a signed volatility level is meaningful. Not retained.

#…ship an automatic converter from another scripting ecosystem?

Because a faithful one is impossible. Flux deliberately excludes what those ecosystems permit — retroactive history edits, unbounded loops, ambient I/O — so a whole class of programs has no faithful translation, and a converter that silently produced something would be worse than none. The documentation teaches the equivalent patterns directly, by hand, which is the honest version of the same help.

#…offer a market-wide screener or portfolio management?

Cross-series work over a handful of named instruments is first-class. Scanning the entire market, and managing many simultaneous positions, are not v1 concerns.

Post-v1. A bounded screener — a total function mapped over a fixed-capacity universe, with a keyed top-K — is a sealed pillar of its own: a bounded map-reduce, never an unbounded scan.

#The questions people ask second

#Is it fast?

The speed comes from algorithms and native kernels, not from the execution language. A bare rsi(close, 14) has nothing to optimize — it is the native kernel. What the design buys you is that the complicated case does not cost what it looks like: shared sub-expressions are computed once, dead code is never computed, element-wise chains fuse into one pass, and the parts of a scene that move the most cost zero JavaScript per frame.

#What happens when I make a mistake?

You get a sentence, not a stack trace: what you did, what the kinds were, what it would have meant, and a quick-fix. A refused repaint is explained — "this would make a past value change once the bar closes" — because the refusal is the feature.

#Can I trust a script someone else wrote?

That is the question the whole design answers. It runs in the same sandbox as ours, with only the capabilities its manifest declared and you granted; that manifest travels with the binary, is inspectable before you install it, and aggregates transitively so a dependency cannot hide its appetite. And whatever it draws, it cannot touch the numbers your decision rests on.

#What is genuinely hard about Flux?

Two things, honestly. Totality is a real constraint — you cannot write an unbounded loop, and some algorithms have to be re-expressed with a declared cap. And the asynchronous chain in the APP plane is verbose — every step is a message, because every step must be in the journal for replay to work. Both are prices, and both are paid on purpose.

#See also

↑ contents

Glossary

Every term this documentation uses with a precise meaning, defined once. Where a term has a casual meaning elsewhere and a sharp one here, the sharp one is what is meant.

#The model

Stream. A value over steps. Every value in Flux is one; a constant is a degenerate stream. There are no indices and no loops over time — arithmetic is element-wise and incremental underneath.

Kind. Flux's word for a type. It carries the value's dimension, not only its shape: price, level, osc(0,100), signal, clock. Kinds are what make meaningless arithmetic fail to compile, and what let presentation be inferred rather than configured.

Sort. A stratum of the kind lattice: scalar, categorical (color, clock, string, ui), structural (vec, record, variant). Across two sorts, the join is and the meet is .

Lattice. The partial order over kinds, with its join (, unification) and meet (, constraint). Finite by family, which is what lets its laws be checked by enumeration.

Join () / meet (). Unification (the branches of an if, two co-plotted series) and constraint (what a parameter demands). Note that price − price = level is not a join — it is an operator rule.

(top) / (bottom). The single error channel, and the kind of na. is a hard error only in a demanding position; elsewhere it is a warning.

Demanding position. An operand, a plot target, an argument — a place where a value is actually consumed. A there is [ErrDim]; a in an unconsumed binding is [WarnTop].

Antichain. The dimensioned kinds (price, volume, time, …), which are pairwise incomparable and rise to quantity rather than to num — deliberately, so that a mixed plot cannot be silently well-typed.

lit. A const-folded literal, polymorphic in dimension: it coerces safely into every scalar. It is why close + 10 needs no cast.

quantity. The erased dimension — the top of the scalar sort. Plottable, but a smell, and it warns.

Tag. An annotation carried orthogonally to a kind's dimension: the numeric representation (f64 / decimal), the time representation (machine / calendar), the asset identity (B, Q [, @v]), the currency pair on ratio, the unit meas[u] on num.

#Time and causality

Clock. The step axis of a series, as a first-class kind: tf("1h"), renko(box), pnf(box, rev), range(r). Time-coarse and price-driven axes are the same concept.

@ (resample). The eliminator of a clock. e @ c reads e on the clock c, taking the last closed unit — never the one still forming.

Causality. output[t] depends only on inputs[0..t]. Enforced by past-only delays, closed-unit resampling, and the rule that every feedback cycle crosses a unit delay.

No-repaint. The consequence: a value, once produced for a step, can never change. Live and historical evaluation produce the same bytes. Repaint is not discouraged — it is inexpressible.

Warm-up. The initial steps where a kernel has not yet seen enough data and yields na. A Flux indicator inherits exactly its kernel's warm-up, which is what makes byte-identity hold from the first bar.

Lag (confirmation lag). A value that is na for L steps and then final forever — a confirmed pivot, for instance. Not a repaint: nothing that was emitted changes.

live(e). The reader of the forming bar. Per-frame, display-only: it may flow to plot, mark, fill, color bars and scenes, and nowhere else. Anything else is [ErrFirewall], and a script that uses it is flagged non-replayable.

As-of alignment. Aligning a foreign series onto the chart's axis by taking the most recent foreign step at or before the current time — a floor-containing lookup, never a nearest match (which would read the future).

na. An absent value. It propagates through arithmetic (the kind is preserved), makes every comparison yield na (test with is_na / is_some), and has one canonical bit pattern at every storage boundary — which is what keeps two engines agreeing about the bytes of a value that is not there.

#The planes

Plane. One of the four execution contexts, each with its own clock and its own rules: ANALYSIS (the bar; total, causal, deterministic), CANVAS (the frame; presentation), TRANSITION (the frame; interpolation between computed states), APP (events; state and effects). You never declare a plane — it is inferred from what you write.

Firewall. The one-way rule: presentation may read analysis; analysis may never read presentation. Violating it is [ErrFirewall].

Stratum (a) / stratum (b). Within a scene: the retained geometry (a pure function of the model — deterministic, inside the replay oracle) and the per-frame cosmetics (glow, pulse — routed to the host compositor, outside the oracle, and firewalled from the model).

Settle vs trajectory. A transition's settle (where it lands) is in the oracle; its trajectory (how it gets there) is not. That is why reduced motion changes nothing that matters.

#The application plane

Model. An application's bounded, typed state. Only bounded kinds may appear in it; anything else is [ErrState].

Message (msg). The only way anything ambient enters an application — time, input, randomness, the network, analysis values. Everything is journaled.

Journal. The ordered list of messages. It is the single source of truth: re-folding it reconstructs the model bit for bit. Undo truncates to a bound, not to the previous message.

Command (Cmd). An outgoing effect, as inert data — a name, a key, a score. Never a socket, a token or a handle. The host executes it, under a capability.

Subscription (Sub). A declarative input, recomputed from each model, carrying the constructor the host will wrap its payload in (OnTick(1000, Tick)).

Slotmap. The official pattern for a bounded collection with stable removal: a bounded vector, a tombstone (na) instead of a compaction, a live mask, a count, and a dual identity — a domain id for messages and persistence, a slot/generation handle for execution.

Capability. A named permission (net:fetch, storage:own, chart:read). Default-deny; a script emits a request, never holds the resource; an ungranted request is a compile error ([ErrCapDenied]).

#Compilation and runtime

Graph (DAG). What a program is, after names are resolved and functions inlined: a typed, acyclic dataflow graph. The unit of optimization, scheduling, memory planning and verification.

I6. A leaf node mapped to a native kernel is byte-identical to that kernel, warm-up included.

I7. The interpreter and the compiled module produce the same bytes — checked at every compilation, on hostile data, in batch and live. A divergence blocks the ship.

The gate. The blocking check that enforces I7 (and, in doing so, the optimizer's correctness). Its oracle is the interpreter; its candidate is the very instance that will serve.

Pinned routine. An operation where two implementations could disagree — a transcendental, a decimal division, a Unicode fold, a calendar addition, a random draw, a sort over absent values — implemented once and shared by the interpreter, the module and the server. Not the same algorithm: the same code.

Translation validation. Checking the optimized graph against the unoptimized one, bit for bit, at every compilation. It is what lets the optimizer be aggressive without being trusted.

Liveness plan (peak, not sum). The memory pass: every buffer's lifetime is statically exact, so disjoint lifetimes share a slot and the reported footprint is the peak of what is simultaneously live. The plan itself is a deterministic function of the graph — because the value oracle is blind to layout.

Canonical order. The single topological linearization, obtained by breaking every tie with the pinned lexical node identity. It anchors the memory plan, the random draw index, and the optimizer's tie-breaks.

1 ≡ N. One worker and many workers produce identical bytes. Proven from the purity of the graph and the frozen reduction order — and stress-tested with adversarial assignment.

#Distribution

fluxpack. The distributed artifact: the compiled module, the sealed manifest, the provenance (content hash, compiler version, dependency closure), and the licence.

Sealed manifest. The capability list, derived by the compiler from the source's requests — never self-declared. It travels with the binary and is inspectable before install.

Content addressing. A dependency reference is an exact content hash, not a version range. Two versions of a library are two coexisting units, so the dependency diamond does not arise.

Rebuild gate. Recompiling the same source and lock, on different machines with different thread counts, must produce a byte-identical module.

#Error codes

Code Fires when
[ErrDim] the dimensional algebra has no rule, or tags disagree
[ErrRepr] same dimension, different representation tags, no explicit conversion
[ErrCausal] a cycle with no unit delay, or a non-causal read
[ErrTotal] a bound that is not a constant, or exceeds the cap
[ErrTotalRec] / [ErrTotalType] a cycle in the call graph / in the type-reference graph
[ErrTotalMatch] a match that does not cover every case
[ErrFirewall] analysis reads a non-deterministic presentation value
[ErrLen] two declared vector lengths are incompatible — capacities that cannot widen
[ErrField] a missing or unknown field
[ErrArg] an argument's kind is not admissible
[ErrPlot] the value is not presentable
[ErrUnbound] an unbound identifier or a syntax hole
[ErrState] an unbounded Model field (APP plane)
[ErrCapDenied] a request for a capability the manifest does not grant
[ErrCapRevoked] a command under a capability revoked mid-session
[ErrSceneBudget] a scene exceeds its draw-list, instance or GPU budget
[WarnTop] an unconsumed binding lands on or quantity
[WarnBranchDim] branches or co-plotted series of different dimensions
[WarnScale], [WarnAffine], [WarnLit], [WarnNaNChain], [WarnBoundsØ] see Inference

#Constants

Name Value What it bounds
N_max 10 000 (browser) / 100 000 (server) the maximum window, period or delay of a kernel
maxNodes 3 072 the size of the graph, judged after optimization
N_active 16 co-active scripts merged into one graph
maxBricksPerBar 1 000 boxes one time bar may cross in a price-driven representation

#See also

↑ contents