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
#Reading paths
- New to Flux — read the book in order: What is Flux → Design pillars → Getting started → The four planes.
- Writing programs — the cookbook, then the FDK reference for the API you need, with the language reference at your elbow.
- Evaluating the guarantees — Guarantees, then Compiler & runtime and Kinds.
- Extending the host (representations, tools, panes) — Host integration.
#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
- Kinds are written in code style:
price,level,osc(0,100),signal,vec(κ, N),record{…}. - Error codes are verbatim and load-bearing:
[ErrDim],[ErrCausal],[ErrTotal],[ErrFirewall],[WarnTop]… - Invariants are numbered I1–I7 (the clock/determinism contract); amendment rules A1–A15 refine the kind system.
- Sample lines carry teaching annotations:
plot x // level → centered pane; rejected forms are marked// ✗ [ErrDim] …. - Post-v1. / Reserved. / Open decision. label design-deferred material inline.
#See also
- What is Flux — start here if the language is new to you.
- Getting started — the first program, and what it infers for you.
- Kinds — the dimensional type system everything else leans on.
- Guarantees — what is promised, and how each promise is machine-checked.
- FDK overview — the libraries and the capability model.
- Glossary — every term, precisely defined.
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:
- A constant is a degenerate stream — the same value at every unit.
2andcloseare the same kind of thing, so they combine freely. - Arithmetic is element-wise.
fast - slowsubtracts two entire histories, unit by unit — yet the engine evaluates it incrementally, one new value per new unit. - There is no index and no loop. You never write
for i, never manage a buffer, never decide when to recompute. You describe what a value is, not when to update it.
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 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-repaint — a 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:
closehas kindprice;rsimaps any scalar quantity toosc(0,100)— a bounded, dimensionless oscillator.- An
osc(0,100)shares no scale with price, so it materializes in its own pane rather than on the price chart — with a fixed 0–100 scale. - The conventional guide lines at 30 and 70 come from the operation's metadata and are drawn automatically.
input(14)declares a parameter: the editor derives a control for it, the tuned value lives with the chart instance (the source keeps the default), and adjusting it re-runs the graph without recompiling.- The series is registered — name, render style, semantic class, even its accessibility description — all derived from the kind.
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:
| 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:
- Tier A — first-party. The platform's own interface, including its most demanding tools, is written in Flux. Its source stays private and ships as compiled WASM — the strongest possible statement that the language is sufficient for real applications.
- Tier B — authors and users. Anyone can write indicators, representations, drawing tools and applications. A shared or purchased artifact arrives as WASM only, never source: the author's work is protected, and the consumer runs an untrusted binary safely — because scripts are total (they cannot run away), sandboxed (the language has no I/O primitives to abuse), and default-deny: every effect requires a capability, and an artifact carries an inspectable, transitively aggregated manifest of everything it may ask for, visible before installation.
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:
- No free-running loops. Flux is total, not Turing-complete — by choice. Windows,
folds and loops exist, but every bound is a compile-time constant under a cap. A program
that cannot state its bound is rejected with
[ErrTotal]at compile time rather than killed by a timeout at runtime. (Open decision. An opt-inunsafeescape for an unbounded loop is discussed in the design and discouraged by default; nothing in the catalogue needs it.) See Design pillars. - No direct I/O from scripts. The language has no fetch, no DOM, no eval, no file handles. Effects are host-mediated: a script emits inert command data under declared capabilities, and the host executes it. This is what makes running untrusted code a routine act rather than a risk assessment.
- Bar-centric time grain. The ANALYSIS clock advances on closed data units. Tick-level and order-flow granularity are a named post-v1 extension, not an implicit promise.
- External non-price data stays out of ANALYSIS. Network-fed data enters the APP plane
through typed subscriptions; it cannot silently become an "indicator". Reserved. The
metrickind names the seam through which a causal external data stream could later enter ANALYSIS, deliberately held open and inert in v1. - No market-wide scan, no portfolio management. Cross-series work over a handful of named instruments is first-class; scanning the whole market, and managing many simultaneous positions, are not v1 concerns. (A bounded screener — a total function mapped over a fixed-capacity universe, with a keyed top-K — is a sealed Post-v1. pillar; it is a bounded map-reduce, never an unbounded scan.)
- No automatic code migration. Flux does not ship a converter from other script ecosystems. Its semantics deliberately exclude patterns some ecosystems permit (retroactive history edits, unbounded loops, ambient I/O), so a faithful automatic translation is impossible for a whole class of programs; the documentation instead teaches the equivalent Flux patterns directly.
- Post-v1. Strategy backtesting harnesses, remote alert delivery, and multi-device sync
are designed but deferred; local alerts (
alert) are v1.
#How to read this documentation
The book (this section) builds the mental model in order:
- What is Flux — this chapter.
- Design pillars — the seven guarantees, why each exists, and what each buys you.
- Getting started — a guided first session, from one line to a small application.
- The four planes — ANALYSIS, CANVAS, TRANSITION and APP in depth, including the firewall and live values.
Around the book:
- Reference — the normative language: lexical structure, grammar, kinds, operators, inference, time and state, canvas, transitions, the APP plane.
- FDK — the Flux Development Kit: the stdlib prelude and the pillar APIs (compute, collections, color, text, i18n, units, net, display, host services, server, asset/currency) plus the capability catalogue.
- Internals — how the guarantees are implemented: compiler and runtime, memory model, optimizer, concurrency, packages, host integration.
- Guides — the cookbook, the guarantees page, the editor, the FAQ and the glossary.
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
- Design pillars — the seven guarantees behind everything above.
- Getting started — write your first program in the next ten minutes.
- The four planes — the plane model and the firewall, in depth.
- Guarantees — what is promised and how each promise is machine-verified.
- FDK index — the author-facing API surface and the capability model.
- README — the documentation map and implementation status.
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.
#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.
- Guaranteed termination. No script hangs, ever — yours or anyone else's.
- A static budget. Memory and cost-per-unit are computed at compile time; a program that
exceeds the budget is rejected before it runs with
[ErrTotal], with the offending bound named — never killed mid-run by a timeout. - Sandbox by construction. A total program with no I/O primitives cannot be weaponized into a resource-exhaustion attack. This is the foundation the trust tiers stand on.
- Verifiability everywhere else. Static checks (kinds, causality, exhaustiveness) are decidable because the language is total and its bounds are constants.
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:
- Delays are past-only.
x[n]reachesnsteps back, withn ≥ 0a constant. A negative delay does not parse into a meaning; it raises[ErrCausal]. - Resampling reads only closed units.
x @ "1d"reads the last closed daily unit — never the one still forming, and never a future close. (The forming unit is reachable for display only, throughlive(), which the firewall keeps out of every analysis.) - Feedback must pass a unit delay. Any cycle in the graph must cross
x[1](the rulescanobeys internally), so today's output may depend on yesterday's output but never on itself.
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-repaint —
a value, once produced for a step, never changes — and it holds for every accepted
program, not for a careful subset.
What it buys you.
- Live ≡ historical. The stream you compute live and the stream you replay over history are the same function — the honest backbone of any evaluation.
- Trustable signals. A crossing that fired stays fired. Marks and alerts stand on ground that cannot shift.
- Machine-checkable. Causality is a graph property (no zero-delay cycles), decided at compile time — the same clock-calculus idea proven in synchronous languages.
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.
- Scalar
f64, no SIMD in the deterministic domain, no FP reassociation. Vector reordering and re-associated reductions change bits; Flux's deterministic core refuses both, and reduction order is fixed. - Transcendentals (
log,exp,sin,cos,tan,atan,atan2,pow) route through one pinned libm build — never the host engine'sMath, which legitimately differs by an ULP between engines. - Decimal, Unicode, calendar, PRNG. Fixed-point money math, string operations (Unicode scalar units, pinned case tables), calendar arithmetic (pinned time-zone data), and the seeded random generator (a counter-based integer PRNG) are each one shared routine, used identically by the interpreter, the compiled module and any re-executor.
- A canonical
na. Missing values compare as absent everywhere, and at every storage or hashing boundarynais forced to a single bit pattern — so two engines never disagree on the bytes of "nothing".
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.
- Replay-exact debugging. Step backwards as reliably as forwards; every value is reproducible on demand.
- Tests that mean something. A golden snapshot either matches exactly or the program changed. There is no tolerance to tune and no flakiness to excuse.
- Cross-machine agreement. Two devices — or a client and a verifying server — compute identical results, which is what makes shared artifacts and independent verification honest.
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.
- Nonsense is rejected at compile time, with a dimensional explanation and a suggested
fix — not a mystery
NaNthree steps downstream. - Presentation is inferred. The kind is rich enough to derive display: a
priceoverlays the price axis; anosc(0,100)gets its own pane, a fixed scale and its guide lines; asignalrenders as marks. One line of mathematics materializes as a correctly furnished chart because the kind said everything needed. - Inference is silent until you are wrong. You annotate nothing; kinds flow bottom-up from sources through operators, every expression gets its most precise kind, and the only time you hear about the system is when it saves 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/noise — exists, 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.
- Guarantees that survive decoration. An animated, randomized effect can sit pixels away from a signal without any possibility of contaminating it. "No-repaint" holds even in programs full of animation, because the dependency cannot be expressed.
- Freedom where it is safe. CANVAS authors get wall-time, easing, springs, noise and interactivity without a determinism tax — the plane is honestly outside the guarantees, and the firewall is what makes that honesty affordable.
- A clean audit surface. Whether a program is replayable is a static fact about which plane its sinks live in, and the compiler tells 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:
- Commands are inert data. A
Cmdcarries values — a sound name, a storage key, a score — never a token, URL or handle. The host holds every resource and executes the command only if the corresponding capability was declared and granted. Emitting a command the manifest does not grant is rejected at compile time ([ErrCapDenied]) — it never becomes a runtime event to be caught. - Capabilities are declarations, not values. They are named
namespace:verbentries in the app descriptor —chart:read,storage:own,net:fetch— never first-class objects, so they cannot be re-delegated or amplified from inside a script. - Manifests aggregate transitively, with zero escalation. An artifact's sealed manifest is the union of capability needs over its whole dependency closure, intersected with the user's grant. A dependency's need surfaces in the top-level manifest before install, and no dependency can ever hold a capability the user did not grant to the whole.
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.
- Untrusted code as a routine. Installing an artifact is informed consent to a short, exact list — not an act of faith in an author.
- No confused deputies. Authority flows only along import edges, capped by the grant; a library cannot launder access through the app that embeds it.
- First-party honesty. The platform's own applications run under the same regime with the same manifests — trust is a grant level, never a code path.
#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.
- Free sharing. Common-subexpression elimination is global: write
ema(close, 26)in four places within a script and it is computed once. Post-v1. Sharing it across four co-active scripts — one merged graph for the whole chart — is a later optimizer tier. - Cost you can see. Dead code is eliminated, constants folded, element-wise chains fused; the editor's cost gutter shows the optimized graph, so what you read is what you pay.
- A safe default and a labeled fast lane. The default is bit-exact, always. Post-v1. Relaxed floating-point (reassociation, fused multiply-add) is designed as an explicit, labelled opt-in — never a silent default, and never bit-exact.
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:
- Totality makes verification possible. Every static analysis in the language — kind inference, causality checking, exhaustiveness, the optimizer's validation oracle — terminates because programs are total and their bounds are constants. A language with unbounded programs could promise none of its checks complete.
- Causality makes the graph a DAG, which is what kind inference walks in one pass, the scheduler parallelizes without locks, the debugger checkpoints and replays, and the optimizer reorders safely. One acyclicity theorem, consumed four ways.
- Determinism needs totality and causality — replay is only exact if programs terminate and history is immutable — and needs the firewall, which keeps wall-time and randomness out of the deterministic domain instead of asking authors to be careful.
- Kinds feed everything. Presentation inference (pillar 4), the memory plan that makes budgets static (pillar 1), the firewall's classification of sinks (pillar 5) and the optimizer's cost model (pillar 7) all read the same dimensional facts.
- Capability security stands on totality and purity. Default-deny would be theater if a script could loop forever, reach ambient I/O, or hide effects in evaluation order. Because it can do none of these, the capability manifest really is the complete story of what an artifact can do.
- The optimizer's freedom is determinism's dividend. "Bit-identical to the reference" is only a usable law because a reference answer exists — deterministic, byte-stable, on every machine. In exchange, the optimizer repays the budget that totality promised: the cost the compiler declared is the cost you observe.
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
- What is Flux — the mental model and the four planes at a glance.
- Getting started — the pillars as you experience them while writing.
- The four planes — the firewall in depth.
- Kinds — the dimensional system behind pillar 4.
- Time and state — delays, clocks,
scanand causality behind pillar 2. - Guarantees — each promise and the machine check that enforces it.
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:
closeis aprice.rsiis a bounded oscillator family, so the expression has kindosc(0,100).- Because the kind is a bounded oscillator, it cannot share the price axis — it gets its
own pane, with a fixed 0–100 scale and a midline. Because the operation is
specifically
rsi, its conventional 30/70 guides are drawn too. - Because you wrote
input(14), a parameter control appears, typed and ranged. - Because the program is causal and total by construction, it is also replayable, bounded, and byte-identical on every engine — which the guarantees panel will tell you without being asked.
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
- The four planes — the firewall in depth, and what
live()is for. - Kinds — the type system that produced every default above.
- Time and state — delays, windows,
scan, clocks, warm-up. - Inference — the presentation table, the overrides, the error catalogue.
- Cookbook — working recipes across every plane.
- FDK overview — the libraries: compute, collections, text, net, display.
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 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:
- The confirmed series of
estays byte-identical.live()adds a provisional view; it does not modify what was computed. live()is excluded from the byte-identity oracle, exactly as the wall clock is — so the guarantee that the two engines agree is untouched.- A script that uses it is flagged non-replayable in the guarantees panel. You see the trade-off you made.
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
- App plane — the full application contract.
- Canvas — signals, spaces, events, primitives, the performance model.
- Transitions — morph, replay, focus, and the transition descriptor.
- Time and state — causality, clocks,
live()in depth. - Guarantees — what each plane promises, and how it is verified.
- display — the two strata, and where presentation determinism ends.
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:
-
(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 ormatchneeds no commas at all:flux
def 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 -
(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: inx = p.onthe trailingonis 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.) -
(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,sandbarsare 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.50dcan never be misread,dalone is never stolen from the author, and each literal arrives at inference already carrying its kind. The one relaxation —200 barswith a single space — is accepted becausebarsis a reserved head there and the span form reads as prose inevery(…)andlife: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:
- Maximal munch in the
?family: the lexer tries??, then?., then?. Soa??bis a coalescing,a?.ba safe navigation, anda ? b : ca ternary — no spacing tricks needed, exactly as..wins over.. ..and->are single tokens. Neither is ever two characters glued at parse time, sobb.upper..bb.lowercannot be misread (..never appears inside an expression's postfix chain) and no second arrow spelling exists anywhere in the language.|has no operator role. It appears only between the constructors of avariantdeclaration (variant Phase { ask | suspense | revealed }). Boolean disjunction is the wordor; there is no bitwise-or operator (bitwise operations are the named functions ofbits.*). This keeps|collision-free with every expression level.
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:
- statement heads (ANALYSIS):
plot mark fill alert assert bars input - CANVAS heads and event verbs:
on group repeat spawn burst emit rate tween flash bounce pulse shake set when every hover click drag enter exit move wheel - CANVAS primitives:
dot circle ring rect square triangle poly line path text image svg sparkline backdrop column - TRANSITION heads and connectors:
morph over focus replay scene - representation heads and hooks:
representation transform render reduce liveReduce updateLastUnit persistKey - drawing-tool heads and hooks:
tool barExtent priceExtent - APP-plane member heads:
capabilities init update subs contributes view - tooling and modules:
test import type - clause connectors reserved contextually at their clause:
at(inassert … at),color(incolor bars:)
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 wherefocus,rectanddotare 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 anIDENTis 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,rangeorzwould poison huge swaths of ordinary naming (every rectangle has arange, every record anopensomething), 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
- Grammar — the normative grammar these tokens feed; the single arrow; precedence.
- Kinds — the dimensional kinds that typed literals (
DUR,PCT,SPAN,DEC…) carry. - Operators — the dimensional algebra behind
+ - * / %, the?family, UFCS. - Time and state — what the
@clock suffix means and why it is restricted. - Text — the
stringkind, formatting (fmt.*) and the interpolation pipeline. - Getting started — the same tokens met in program order.
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
#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 production — Arrow { lhs, rhs } — with five readings
selected entirely by context. There is no second arrow symbol anywhere in the language.
| # | 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:
- Guarded arrows never reach the expression grammar. In
on EVENT -> ACTION,match e { pattern -> expr }andfor x in coll -> child, the operand before the arrow is acondExpr— a stratum that excludesarrowExpr— so the event, scrutinee and collection can never absorb the->. The arrow is claimed by the head's own production, with zero choice for the parser. - Unguarded arrows build one tree; the role is decided at inference.
a -> bin an expression parses as the singleArrownode whether it will act as a lambda or as a tween pair. Kind inference reads it as a lambda exactly when the left side is a paren form of bare identifiers and the position expects a function; otherwise it is a pair of values (6->24). The same tree also serves the dedicated pair productionarrowPairused bytweenand property values.
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 | — |
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 < chas no boolean reading in a dimensional language (the first comparison yields asignal, which is not ordered againstc— 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:
- The build is the ambiguity proof. The normative grammar is expressed once, as a Lezer LR grammar; an LR generator reports every conflict at build time, so "the build is green" is a machine check of non-ambiguity — not a review claim. (An ordered-choice formalism was rejected for exactly this reason: it does not detect ambiguity, it silently hides it.) The same artifact drives the compiler, the editor's syntax services and the documentation tooling, so there is nothing to drift.
- The corpus round-trips. Every example in the corpus is parsed, printed by the canonical formatter, and re-parsed; the two trees must be identical. A grammar bug and a formatter bug break the same gate. (The corpus is also the golden suite, so an example cannot rot without a test going red.)
- The parser is total. Fuzzing asserts that any byte sequence either parses to a unique tree or is rejected with clean diagnostics — never a crash, never a hang. Malformed input is part of the language's domain.
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:
- A valid script stays valid indefinitely. The semantics of an existing construct is never altered; the surface only grows. Every extension must itself prove zero conflicts at the grammar build before it lands — additivity is a build gate, not a promise.
- Language versions are declared, optionally. Each source file may carry a language
version marker; a file without one means "current". (An app's data schema — its
Msgvariant and model — is versioned separately by the app itself; see the APP plane.) - Keywords are reserved ahead of need. Words destined for future surface are reserved
before their productions ship, so no program written today can shadow tomorrow's syntax —
testis the standing example (keyword model). Combined with contextual reservation, this makes future growth collision-free by construction. - The codemod promise. If a deprecation ever became unavoidable, an automatic codemod would rewrite affected sources at load — mechanically, not by asking authors to migrate by hand. Nothing in v1 is deprecated.
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
- Lexical structure — tokens, the significant newline, the keyword tiers.
- Kinds — the dimensional lattice the parsed tree is checked against.
- Operators — the per-operator dimensional algebra, UFCS,
with, the?family. - Inference — how kinds (and the lambda-versus-pair arrow) are decided on the tree.
- The CANVAS plane · The APP plane — semantics of the statement families above.
- Guarantees — the trust page: every machine-verified property in one place.
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:
- (A) The coercion lattice
(K, ≤, ⊔, ⊓, ⊤, ⊥).a ≤ bmeans "acoerces tobwithout lying about itself". The join⊔unifies — the branches of anif, the two arguments ofnz, series co-plotted in one pane. The meet⊓constrains — the kind a parameter demands, the intersection of oscillator bounds. The lattice answers "is there one kind that covers both?" — nothing more. - (B) The dimensional operator algebra for
+ − × ÷ < > ==— a rule table that computes the result kind of each operation from the kinds of its operands, in the style of classical dimensional analysis. This is not the join: it can move across the lattice (price − price → level) or refuse outright (price + osc → ⊤). - A single error channel:
⊤. Every undefined rule in (B) and every incompatibility in (A) produces the same top element⊤. A⊤is a hard error in a demanding position — an operand, aplottarget, an argument — and an operand is itself a demanding position, which is whyclose + rsi(…)fails at the+, whether or not anything downstream reads it. A⊤that reaches no demanding position at all is a warning ([WarnTop]), not a failure.
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.
price − price → level flows through B, never through A.
Why this rule exists. If
−were modelled as a join,close − openwould have kindprice ⊔ 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 ema26would 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:
priceis a point on that axis,levelis a vector — a displacement between points,ratiois a dimensionless scalar.
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 spine — lit < {ratio, osc(lo,hi), signal, dir, depth} < num:
lit— a const-folded literal, polymorphic in dimension:lit ≤safeevery scalar, dimensioned or not. This is what makesclose + 10,close > 30000andnz(x, 0)legal without ceremony: the literal adopts the kind its context gives it.ratio— dimensionless, centered on 1, multiplicative (price ÷ price, band width).osc(lo,hi)— dimensionless and bounded, an interval-parameterized family (rsi → osc(0,100),cmo → osc(-100,100),cmf → osc(-1,1)). See the interval sub-lattice below.signal— a boolean event,{0,1}(comparisons,cross_up,rising,in_session).dir— a direction,{-1, 0, +1}(rule A6): categorical-discrete, the flat sibling ofsignal(dir < num). It types the direction field of trend structures (superTrend(…).dir), is presented as bar coloring or marks, and is discriminated by comparison (st.dir == 1), never bymatchand never by<. (There is no unary+in the grammar: the value+1is written1.)depth— a dimensionless z-intention tagged@z, produced by analysis and consumed by the presentation z channel (projected in 3-D, flattened in 2-D).num— dimensionless of unknown role: the least upper bound of the spine.kdj(…).jand calendar accessors land here.
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², 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 P² 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:
color— an RGBA style value, consumed by fills, bar coloring and style channels (Color).clock— a resampling schedule: an ordinal index with constructorstf("1d"),renko(box),pnf(box, rev),range(r). Aclockis a first-class value (you can select one with anif, take one as aninput) but it is eliminated only by@(Time and state); it is never plotted and never compared.string— bounded, immutable UTF-8 text (rule A12): labels, prompts, alert messages.stringis flat likecolorand outside numeric arithmetic, with exactly one operator overload:string + string → stringis concatenation — the single categorical line of the+rule table. There is nostring < string(locale-dependent collation is excluded by determinism), equality is bit-equality, and astringis consumed by text-expecting channels — it is never plotted as a series (Text).
#The structural sorts — vec and record
Structural kinds compose componentwise; mismatched shapes are nonsense and resolve to the extrema.
vec<κ>[n]— a vector ofκwith const-folded capacityn(n ≤ N_max = 10 000). Capacity, not exact count: see below. Introduced bywindow(e, n), list literals,vec.fill(N, x)andvec.range(N); covariant inκ.Notation.
vec<κ>[n]is the metalanguage of this documentation. In source, the same kind is writtenvec(κ, N)— with parentheses, because<and>are exclusively the comparison operators and never type delimiters.record{f₁:κ₁, …}— a named product. This sort is load-bearing for the catalogue: a large share of the built-in kernels return records —bollinger → record{upper, middle, lower: price},macd → record{macd, signal, hist: level},superTrend → record{st: price, dir: dir},adx → record{adx, plusDi, minusDi: osc(0,100)}. Projection ise.f; functional update ise with { f: v }(shape-preserving — unlisted fields carry over, unknown fields are[ErrField]).
#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:
variant{Tag₁:κ₁ | …}— the tagged sum, categorical dual ofrecord(the product). Its introduction is the constructor injectionTag(e), its eliminator ismatch(symmetric to projection). Order, join, meet and⊤-propagation are derived identically torecord: label sets are invariant (same labels → refine per payload, covariantly; different label sets → incomparable, join⊤, meet⊥). Messages, commands and subscriptions of an app arevariants (The APP plane).ui— the view-primitive sort: a closed, vetted set of primitives, flat likecolor, consumed by the host renderer, and never coercible to a scalar (ui ⊔ κ = ⊤,ui ⊓ κ = ⊥across sorts — a primitive in scalar position is caught immediately).
#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
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 dimension — price ⊀ num. Dimensioned kinds rise to
quantity, never to num.
Why this rule exists. If
price ≤ numheld, thensignal ⊔ 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 anifmixing incompatible things). By routing all dimensioned kinds toquantityinstead, a mixture is never silent: in arithmetic it is refused ([ErrDim]), and in branch reconciliation it compiles toquantityand 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:
≤safe— silent.⊥ ≤ κ ≤ ⊤for every κ; wideningoscbounds (osc(0,100) ≤ osc(-100,100)); wideningveccapacity (vec<κ>[k] ≤safe vec<κ>[N]fork ≤ N); the spine edges{ratio, osc, signal, dir, depth} ≤ numandnum ≤ quantity; andlit ≤every scalar.≤lossy— compiles, warns, offers a quick-fix. Erasing a dimension (D ≤ quantityfor any dimensionedD), and accepting a non-normalized affine combination. Loss of information is legal but never silent.
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:
signal ⊔ price = quantity, not⊤(correction L2).quantityis a common upper bound of both (signal ≤ num ≤ quantity,price ≤ quantity), so⊤would not be the least one — and the error policy wants exactly this outcome: mixed-dimension branch reconciliation is a warning ([WarnBranchDim]on a plottablequantity), while mixed dimension in an arithmetic position stays a hard[ErrDim]. The boundary between hard and soft runs between algebra and reconciliation, not through the lattice.dir ⊔ signal = num, notquantity. Both are dimensionless spine leaves belownum, sonumis their least upper bound — exactly asosc ⊔ ratio = num. Promoting toquantitywould put a larger element above an existing upper bound and destroy uniqueness of the LUB.
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:
±(equal dimension; affine):pt(d) − pt(d) → vec(d)·pt(d) ± vec(d) → pt(d)·vec ± vec → vec·ratio ± ratio → ratio·osc ± osc →propagated interval ·lit ± x → x· differing dimensions →⊤. One categorical overload:string + string → string(concatenation), and nothing else touchesstring.- Affine combinations (
(high + low) / 2): the expression is normalized toΣ λᵢ·ptᵢwith const-folded coefficients;Σλ = 1 → price(a weighted point),Σλ = 0 → level(a pure displacement), anything else →quantity+[WarnAffine]. Multiplying by alitpreserves the role, sosma(close,20) + 2 * stdev(close,20)types asprice + level → price. Rule A7 applies this after inlining, so recursive composites type correctly:dema = 2·ema − ema(ema)hasΣλ = 1 → price. ×and÷(the group law, rule A2): exponent vectors add and subtract, so both operators are total on dimensions —D × ratio → D,price × volume → pv,price × price → P²(plottable, A3),pv ÷ volume → price,pv ÷ price → volume,level ÷ barspan → slope.- Order comparisons (
< > <= >= cross_*): admitted only on the ordered scalar sort with compatible dimensions (κₐ ⊔ κ_b ∉ {quantity, ⊤}) →signal.price < oscis[ErrDim];color,clock,string,record,vec,variant,uiare never ordered;diris categorical-discrete and outside order. - Equality (
== !=): decidable equality →signal— bit-equality for scalars,string,dir,color; deep,na-aware equality forrecord/vec/variant(mismatched shapes →[ErrDim]);clockanduiare consumed, never compared ([ErrArg]). - Logic (
and or not):signal × signal → signal— there is no arithmetic onsignal.
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:
- a numeric representation tag —
f64(default) ordecimal(scale)(rule A11); - a time representation tag on the
Tdimension —machine(default; exact elapsed time —duration) orcalendar(period, rule A10); - 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.
[ErrRepr]), asset components widen while ± demands identity ([ErrDim]).
The two axes families obey two different regimes — and the difference is the point:
- Representation tags (1, 2) never coerce silently. On two kinds of equal dimension but
differing representation tags, join and meet produce
⊤— surfacing as[ErrRepr]in a demanding position. The only bridge is an explicit conversion (toDecimal/toFloat; theperiodconstructors for calendar time). This mirrors the arithmetic refusal: you do not add anf64 priceto adecimal price, or a machinedurationto a calendarperiod, without saying so. - The asset tag (3) widens per component. Each component has its own top
(
⊤base,⊤quote,⊤venue); a join widens the component that differs and preserves the one that matches —price[B1,Q] ⊔ price[B2,Q] = price[⊤base, Q]— and never raises[ErrRepr]. Safety comes not from the join but from the tagged±algebra:+,−, the comparisons and the affine rule all demand component-wise identical tags, and refuse with[ErrDim]otherwise. Widening keeps composition possible (two venues'price[BTC,USD]can form an index); the identity gate still catches every unit bug.
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 P², 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:
meas[u](rule A14) — generalist quantities: a unit annotation whoseuis a product of catalogue symbols with integer exponents (meas[m],meas[m·s⁻¹],meas[kg·m·s⁻²]), augmented by an affinepoint|deltabit for zero-arbitrary scales (aunit.tempC(20)point versus aunit.tempCDelta(5)displacement — the same point/vector discipline asprice/level, applied to temperature).±and comparisons gate on identical units;×/÷compose exponent maps with exact pinned conversion factors and cancel fully toratio; mixed products with the financial dimensioned kinds are an explicit wall ([ErrDim]). The complete algebra, conversion verb and entrance rules live in Units.
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
- Reserved.
metric[id](rule A15) — identity annotations for non-price series (macro indices, on-chain metrics, analytics). Same-id±preserves, differing ids refuse ([ErrDim]-class),metric[A] ÷ metric[B] → ratio, and kind-preserving families carry the annotation through. The axis is deliberately inert in v1 — no non-price series enters the ANALYSIS plane until the amendment is armed; its design is specified with Asset & currency.
#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:
- Records are monomorphic in v1. A
defthat projects a field requires a concrete record kind known at inference. Post-v1. an open-row extension (row polymorphism) is a named additive extension — never a v1 prerequisite. - The reference graph must be acyclic. A declared
record/variantmay reference other named declarations, but any cycle — direct or transitive — is rejected at name resolution with[ErrTotalType], the kind-level twin of the acyclicdefcall graph ([ErrTotalRec]). Every named kind therefore flattens to a finite-height structure, and the finite-height property that closure and machine verification rest on extends to user declarations unchanged.
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:
- Hard (
[ErrDim],[ErrRepr],[ErrLen],[ErrField],[ErrArg],[ErrPlot],[ErrTotalType],[ErrTotalMatch]): a load-bearing guarantee is violated or the nonsense is certain — a⊤/⊥in a demanding position, a violated side condition, a shape mismatch. - Warning (
[WarnTop],[WarnAffine],[WarnBranchDim],[WarnBoundsØ],[WarnLit]): suspect but possibly intended, or a recoverable information loss — exactly the≤lossytier and the dead-⊤case.
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
- Operators — the full dimensional algebra per operator, UFCS,
with. - Inference — bidirectional kind inference, presentation inference, the
error policy,
na. - Time and state — streams, delay, clocks and
@, causality. - Units — the complete
meas[u]algebra: conversions, affine scales. - Asset & currency — the asset tag,
fxandmoney, venues. - Design pillars — how dimensional joins the other pillars.
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:
- The coercion lattice (A).
a ≤ bmeans "acoerces tobwithout lying". Its join⊔unifies kinds at reconciliation sites — the branches of anif, the two operands ofnz, co-plotted series. Its meet⊓intersects constraints — the kind a parameter demands, overlappingoscbounds. - The operator algebra (B).
+ - * / < > ==are governed by a rule table — dimensional analysis in the tradition of unit-of-measure checkers — that computes the result kind of each application. It is not the join:price - price = level, whileprice ⊔ price = price.
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:
*and/are total on dimensions — there is always a result exponent vector;- only
+and-demand dimensional agreement, because adding a point to a point has no geometric meaning.
Why this rule exists. The affine reading is not decoration; it is what lets the checker say no to
close + rsi(close, 14)while acceptingclose + 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]:
Σλ = 1→ the point kind — a barycentre of prices is a price:(high + low) / 2 : price;Σλ = 0→ the vector kind — a zero-sum combination is a displacement:high - low : level;- anything else →
quantity+[WarnAffine]— dimension erased, probably a missing divisor.high + lowalone (Σλ = 2) draws the warning with a quick-fix: divide by 2 or use the pre-typed sourcehl2.
Two refinements make this rule cover real indicator algebra:
- A literal coefficient preserves the role:
k * stdev(close, 20)is still alevel, which is whysma(close, 20) + 2 * stdev(close, 20)types asprice + level → price— a volatility band lands on the chart, not in a pane. (The full bottom-up walkthrough of this exact expression, ending in its presentation decision, is drawn in Inference.) - The rule applies to composites, recursively, after inlining — not only to syntactic
OHLC averages.
dema = 2*ema(close,20) - ema(ema(close,20),20)hasΣλ = 1and types asprice; so doestema. Without recursive application, every multi-emacomposite would mistype aslevel.
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) / 2would normalise toΣλ = 1and type-check as a validprice, 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 |
P², 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:
slopeis price-per-bar, not price-per-second: the x axis is ordinal, solevel ÷ barspan → slope = P·I⁻¹. Dividing alevelby adurationwould produceP·T⁻¹, a different (and rarely wanted) dimension.lrSlopereturnsslopefor this reason — derived from the group law, not decreed.sqrthalves exponents andpow(x, n)scales them, which is howstdev = sqrt(variance)types correctly; transcendentals (log,exp, trigonometry) demand dimensionless input —log(close / close[1])is fine; the compute pillar makeslog(close)an[ErrDim], with a quick-fix to the ratio.- Open decision. The grammar keeps
%at the multiplicative tier; no catalogue kernel uses it and its retention is a named open item of the grammar plan. Its integer semantics on the integer aliaseslong(≡ decimal(18,0)) andlong128(≡ decimal(38,0)) are pinned (truncation toward zero; the remainder takes the dividend's sign; division by zero andMIN / −1yieldna, never a trap). See compute.
#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:
±and comparisons gate on identity — mixing raises[ErrDim];*,/and the join widen — a differing component rises to its per-component top (⊤base,⊤quote), never an error.
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?):
price[B,Q] ÷ price[B,Q] → ratio(both equal) ·price[B1,Q] ÷ price[B2,Q] → ratio(base differs) ·price[B1,Q1] ÷ price[B2,Q2] → ratio(both differ — no clean conversion exists, so the tag is dropped and you get a bare dimensionless number);price[B,Q1] ÷ price[B,Q2] → fx[Q1/Q2]— only when both quote keys are concrete statics; if either is⊤quote, the result drops to plainratio;price[B,Q1] * fx[Q2/Q1] → price[B,Q2](commutative) andprice[B,Q1] / fx[Q1/Q2] → price[B,Q2]— conversion; the shared quote must match string-for-string, otherwise the result widens toprice[B,⊤quote](never[ErrDim]: safety is±-only);levelandpvconvert by the same two edges;1 / fx[Q1/Q2] → fx[Q2/Q1]— the reciprocal; the numerator must be the literal1, matched before any coercion. A wrong direction is a distinct tag (fx[USD/EUR] ≠ fx[EUR/USD]), never a coercion;fx[A/Q] ÷ fx[B/Q] → fx[A/B]— triangular chaining;fx * fx → num(the pair annotation has no slot onnum);fx ± fx → ratio— a sum of rates is a magnitude, not a rate, so the pair annotation drops (kernels are different:ema(fxUE)keepsfx[USD/EUR]);price[B,Q] * volume[B] → pv[Q]— the base pairs off and is dropped: a money flow is a currency amount, base-agnostic, sopv[USD]of two different assets may be summed — that is portfolio arithmetic, not the asset-mixing bug;- cross-asset
*widens:price[B1,Q1] * price[B2,Q2] → P²[⊤base,⊤quote], never an error.
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, ⊤}):
- ordered: the numeric spine (
ratio,osc(·),signal,num) and the dimensioned antichain (price,level,volume,pv,time,duration,barindex,barspan,slope,angle); - excluded:
color,clock,string,record,vec,variant,ui—[ErrDim]/[ErrArg].string < stringis rejected deliberately: the core has no locale-dependent collation (a determinism exclusion — see text); diris excluded from order:{-1, 0, +1}is categorical-discrete, not a magnitude. Compare it with==only.
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:
- bit-equality for
string,dir,color— and ordinary scalar equality on the spine (close == open); - deep, decidable,
na-aware equality forrecord,vec,variant— component-by-component; two shapes that differ are[ErrDim]; - excluded:
clockanduiare consumed kinds, never compared —[ErrArg]; - scalar operands of differing dimensions or asset tags:
[ErrDim], exactly as for order; thefxpair annotation is again exempt.
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
nafor its first bars. Ifna > 70silently evaluated tofalse, every threshold rule would fire — or refuse to fire — on phantom data, and the bug would be invisible by construction. Forcingnathrough comparisons makes absence propagate to thesignal, whereis_na/is_somehandle it deliberately. The same discipline runs through the whole language:matchmust coverna, 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
n ≥ 0;x[0]isxitself. A negative index is[ErrCausal]: Flux is no-repaint — a value, once produced for a step, never changes — and a read into the future is not a style violation but an unexpressible program.- A non-constant index is
[ErrTotal]: the delay depth sizes a ring buffer, and totality demands that all memory be bounded at compile time. - On the first
nbars,x[n]isna(warm-up), which the comparison rules above then carry safely.
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
- The resample is causal: at any bar it sees the in-progress or last closed unit of
the coarser clock, never a future close.
[ErrCausal]guards the rest. - Constructors:
tf("1d"),renko(box),pnf(box, rev),range(r)— whereboxis alevel(the lattice forces it: a brick boundary isanchor + k·box, and onlyprice + levelis a price). - v1 allows one clock per series: composing two clocks (
renko(b) @ "1d") is rejected. - Grammar note: the right-hand side of
@is a restricted clock operand, sox @ "1d" + 1parses as(x @ "1d") + 1.
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:
recv.fwithout(— a record field (bb.upper), rule[Proj];recv.f(wherefresolves to a function — a UFCS call, desugared tof(recv, …);mod.f/T.Ctorwhere 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
recvagainst the function's first parameter exactly as if you had written the direct call — but it buys discoverability: afterclose., the editor can propose precisely the functions whose first parameter accepts aprice, 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]):
- every listed field must already exist —
[ErrField]otherwise (no silent field creation); - each value must coerce to the field's declared kind —
[ErrArg]otherwise; - unlisted fields are carried over unchanged, and the result has exactly
e's kind.
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
withmeans 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
- a named field that does not exist, or a non-record scrutinee, is
[ErrField]; - if the scrutinee is
na, every bound field isna— absence flows inward, consistently with the comparison rules; - variant destructuring is refutable and therefore lives in
match, whose exhaustiveness the checker enforces ([ErrTotalMatch]) — aletnever branches.
#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:
..— non-associative, legal only in range positions (fill a..b,input(n, lo..hi));@— its right operand is a restricted clock operand, sox @ "1d" + 1is(x @ "1d") + 1;:and,— separators, never operators.
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
- kinds.md — the lattice these operators compute over: sorts,
⊤/⊥, coercion edges, tags. - inference.md — how kinds are assigned, presentation inference, and the full error catalogue.
- grammar.md — the normative grammar, the single arrow's five readings, disambiguation.
- time-and-state.md — streams, clocks,
scan/fold/loop, causality and warm-up in depth. - asset-currency — the complete asset-tag model: venues,
toSource, money. - compute —
math.*/stat.*and the dimensional rules of the numeric library.
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 - closecould synthesizelevelor, by the lossy erasure edge,quantity. Both are derivable. Butplot xreads the presentation registry at the kind:levelgives a pane centred on zero,quantitygives 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:
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², 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.
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:
- Overlay if and only if the value shares the price axis. Otherwise it gets a pane.
- Co-plotting joins scales. Two series in one pane take the
⊔of their bounds; if that join lands onquantity, you get[WarnBranchDim]and a suggestion to split the pane. (Apriceoverlay next to aratiopane is not a mix — it is two panes, and it warns about nothing.) - The final kind decides.
level + price → price, so the expression overlays. - Reference lines from convention live in
opMeta, not in the kind.osc(0,100)gives a midline; thatrsiconventionally marks 30 and 70 is a property ofrsi.
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 set — price, 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.
- Every comparison touching
nayieldsna— nevertrue, neverfalse. Absence is tested withis_na(x)and presence withis_some(x), bothsignal. nz(x, d)(and its sugarx ?? d) substitutes a default;max/minabsorbnapointwise, while window reducers propagate it (a window containing a hole yieldsna, matching the native oracle they must stay byte-identical to).- A
matchon a possibly-nascrutinee must cover it — annaarm or_. - Destructuring an
narecord gives every fieldna. - A NaN produced by arithmetic is
na— the kind is preserved, and it is displayed as a gap rather than as a failure. Division by zero is not an error; it is an absent value. - Internally,
nais forced to a single canonical bit pattern at every storage, hashing and serialization boundary. Nothing about that is observable in a program — it is what makes two engines agree on the bytes of a value that is not there (Memory model).
#See also
- Kinds — the lattice, the sorts, the tags, the named declarations.
- Operators — the dimensional algebra rule by rule.
- Grammar — how the modes of inference resolve the arrow and the container heads.
- Time and state — causality, warm-up,
live()and the firewall in practice. - The editor — kind-filtered completion, hover cards, the typable cone in live preview.
- Compiler and runtime — why deterministic inference is a prerequisite for byte-identity.
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:
nis a const-folded constant,n ≥ 0— a literal, or a parameter whose bounded range lets the compiler reserve the worst case. Delay is memory; memory is bounded at compile time.- Before step
n,x[n]isna— the past that does not exist yet (see Warm-up andna). - On a
vec, the same postfix[i]is a bounded element read, not a delay; the kind of the operand disambiguates.
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:
- Any comparison involving
na—na == x,na < x, evenna == na— isna, nevertrueorfalse. You cannot test absence with==. - Absence is tested with
is_na(x) → signal; presence with its dualis_some(x) = not is_na(x) → signal. matchon a value that may benamust cover it (annaarm or_), or the match is rejected as non-exhaustive.- Destructuring an absent record (
let {upper, lower} = bbwhenbbisna) givesnain every field.
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] 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:
vec.where(v, pred)— same length,nawhere the element fails the predicate;vec.mask(v, live)— same length,nawhere the parallelsignalvector is 0.
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 (
naholes, acountwhere 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: 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:
count * box : level— a number of boxes times a box height is a displacement;extreme + count * box : price— anchoring that displacement at the column's extreme gives back a point on the price axis.
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":
cum(x) → α— the expanding sum from the first step (ascanin a coat);cumSince(reset, x) → α— the same accumulation, re-initialized at each rising edge of thereset : signal.
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.
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-hourdurationlands 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:
live(e)re-evaluates the analysis sub-graph ofeper frame, including the forming unit;κ(live(e)) = κ(e)— it is kind-preserving.e @ live(tf("1h"))scopes the reading to the resample: only the coarser clock's forming unit is read live; everything else stays confirmed.- A
live(…)value may flow only into display sinks:plot,mark,fill,color bars, a scene. It is provisional by nature — each frame's value supersedes the last, and none is committed or journaled; when the unit closes, the confirmed value takes over.
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
- kinds.md — the dimensional kind system these streams carry: sorts, tags,
naas⊥. - operators.md — the per-operator dimensional algebra,
?-family,with. - inference.md — how kinds (and warm-up
na) propagate through a program. - canvas.md — the frame clock, presentation signals, and where
live()values land. - fdk/compute.md — dataframes,
asofJoinand the windowed statistics built on these primitives. - guides/cookbook.md — worked recipes: multi-resolution confluence, anchored VWAP, state machines.
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
- The four planes — the firewall, and where canvas sits in it.
- Transitions — the plane that interpolates between two computed states.
- display — the retained scene, the two strata,
viz.*, panes and windows. - Time and state —
live(), clocks, and what "the forming bar" means. - App plane — when interaction needs to remember something.
- color — tokens, explicit colours, and the perceptual interpolation.
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.63mid-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
- The four planes — where TRANSITION sits, and why it is separate.
- Canvas — the plane whose signal algebra transitions borrow.
- display — the two strata, the transition invariants,
prefers-reduced-motion. - Host integration — the transition descriptor as a compilation target.
- Time and state — why an interpolation can never be a repaint.
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.
A note on the samples. Several samples below carry a member of an
appblock — anupdate, aview, asubs— outside its block. A member is only legal inside one, so read those samples as if the member sat withinapp name { … }; the type anddefdeclarations 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:
- A level editor — every stroke is a message, so undo brings the drawing back exactly: the same result, because the same fold.
- A game — rewind to any move and replay from there; the whole match is the list of moves, and any prefix of it is a valid state.
- A blotter — scrub the position history like a video, because the history is the journal.
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:
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:
- Removal writes a tombstone, never compacts.
vec.setAtis total and length-preserving; out-of-bounds leaves the vector unchanged. There is no indexed assignment in Flux — purity is not negotiable. - Iteration is
na-aware.vec.mask(slots, live)keeps the length and blanks the dead slots; the comprehension skips them. (vec.where(slots, is_some)is the same idea with a predicate instead of a parallel signal.) - Identity is dual. The domain id — a monotone counter in the Model, or a UUID v7 for shared storage — is the script-facing identity: it is what messages, undo and serialization carry. The slot/generation handle is an internal execution index, rebuilt on load. A serialized slot index would be dead on arrival; a domain id survives.
- Overflow is an application state. Filling
Nis not a crash: the add arm returns a message, and the application decides what to say.
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
genon creation makes a stale handle readnainstead. And on load, the persistednextIdis 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 chainA ⤳ B(A) ⤳ Cand 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 fixeddo … 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 planes — chartView,
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 request — emit 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.
- Tier A — the first-party interface (including the level editor). Its Flux source stays private and is never shipped; it ships as WASM. It is trusted by perimeter, and its privileged grants sit behind a triple barrier: an authenticated, admin-gated grant; a host mediator that alone holds the token; and row-level security in the database.
- Tier B — user and marketplace applications. Untrusted from the point of view of whoever
loads them. Strict default-deny, sanitized view, isolated realm. The buyer receives the
WASM alone, never the source — so consent cannot rest on reading the code. It rests on
the sealed manifest, which travels with the binary, is derived by the compiler from the
emit Capsites (their only origin), and is inspectable before install.
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 journal is not a flat list of messages. Two refinements are forced by real editors:
- Undo bounds. A message may or may not set a bound. Undo truncates to the previous bound, never to the previous message — otherwise a drag would come apart into a hundred micro-steps. Coalescing a gesture means placing the bound at the pointer release.
- Checkpoints. Re-folding from
initon every undo is linear in history, so models are memoized at intervals and the re-fold starts from the nearest one.
#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:
- 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. - 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 totalmigrate(old) -> Modelthe 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 port —
rect(), 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:
- 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.
- The APP plane reads analysis read-only, through
Sub OnSeries. - CANVAS reaches the APP plane through events; the APP plane reaches CANVAS through commands — it orchestrates presentation, it does not rewrite it.
- 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
- The four planes — the firewall, plane by plane.
- Kinds —
variant,record,ui, and the bounded kinds a Model may hold. - display — the view primitives, the scene value, panes and windows.
- net —
net:fetch/net:stream, typed payloads, offline queues. - server — headless applications, shared storage, and server-side replay.
- Guarantees — what replay, determinism and the sandbox actually promise.
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:
- Domain-complete, not general-purpose. There is no
filterthat shrinks a list, no regular-expression engine, no unbounded queue, no ambient I/O. Every one of those was left out because it would break totality, determinism, or the sandbox — and for every one of them there is a bounded replacement that does the job the domain actually needs. - Everything is pinned. Where an implementation could differ between two engines — a transcendental, a decimal division, a Unicode case fold, a calendar addition, a random draw, a sort with absent values — the FDK carries one routine, shared by the interpreter, the compiled module and the server. Not "the same algorithm". The same code.
#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:
- A request for an ungranted capability is a compile error (
[ErrCapDenied]), not a runtime exception to be caught and retried. - A capability is never a value. The script cannot hold one, store one, pass one, or re-delegate one. It holds a request.
- A manifest aggregates transitively, with zero escalation. If a package you depend on wants the network, that surfaces in your manifest, visible to whoever installs your app, before they install it — and it is still capped by what the user granted.
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:
- the editor's hover card and its completion list,
- these pages,
- the error messages,
- the snippets.
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
- Getting started — the FDK as you first meet it.
- Kinds — the type system every namespace is built on.
- App plane — capabilities, commands, subscriptions.
- Cookbook — the FDK in working recipes.
- Packages — modules, imports, and third-party libraries.
- The editor — where doc-as-data is actually experienced.
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
- Bounded and total. Every collection is a
vec(κ, N)with a const-folded capacity. The output capacity of every relational operator is statically derivable from its inputs, and no operation ever shrinks. The memory a query needs is computable at compile time; an over-budget query is rejected, never killed mid-run. - Columnar. A table is a record whose fields are columns — struct-of-arrays, the same layout the engine already uses for bars.
- Kind-typed and dimensional. Every column carries its dimension, its numeric representation
and its asset tag. An entire class of bugs — adding a price to a volume, a BTC price to an ETH
price, an
f64to adecimal, joining on keys from different instruments — becomes a compile error. - Pure. A table touches neither the network nor application state, so it lives on any plane without a firewall question.
- Lazy and fused. A query is an inert graph, fused into one columnar pass. Projection pushdown is dead-code elimination; predicate pushdown is fusing the mask into the producing scan; nothing intermediate is ever materialized.
- Byte-identical. Scalar
f64, no SIMD, no floating-point reassociation, every reduction order pinned, every order statistic through the pinnedna-ordering routine, every decimal through the pinned integer routine.
#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 dimensionless — log(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
}
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:
- Projection pushdown is dead-code elimination — a column nobody reads is never materialized.
- Predicate pushdown is fusing the mask into the producing scan.
- Common-subexpression elimination is global, across a whole query, and across scripts.
- Nothing intermediate is materialized. A chain of ten verbs is one pass over the columns.
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:
- Linear algebra — decompositions, solves, eigenvalues for symmetric matrices — each with a frozen reduction order, so the last bit is the same on every machine.
- Statistics — the moment family, order statistics, correlation, the OLS/QR regression path.
- Signal processing — the FFT, bounded and deterministic.
- Randomness — the pinned counter-based integer generator, so a simulation replays exactly.
- Optimization — bounded solvers, with an iteration cap that is part of the type.
#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
- Kinds — the dimensional system every column carries.
- collections — the bounded structures under the tables.
- display —
viz.*, which turns aTableinto a scene. - units —
meas[u], for quantities outside the market domain. - Memory model — the columnar layout and the liveness plan.
- Optimizer — the fusion and the pushdowns, and why they are safe.
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 place (§ Efficiency): the ergonomics of a persistent collection with the performance of a mutable array — because of the constraints, not in spite of them.
#The 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:
- The invariants are protected. There is no way, from a script, to break the sort order of a
Mapor corrupt the cursors of aDeque. The states that would violate the invariant are not merely rejected — they are not expressible. - The implementation is free. The host picks the backing — a sorted array, a bounded B-tree, a pinned hash — and can change it without changing a line of user code, because no user code ever saw it.
- Every operation is a pinned routine. Container operations join the pinned set (
decimal, the codecs,fmt.*, the total order overna): one routine, shared by the interpreter, the compiled module and the server. Invariant I7 — interpreter ≡ compiled module, byte for byte — therefore holds through aMapexactly as it holds through anema, and replay stays exact.
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:
- In kind position the sequence keeps its frozen surface form — lower-case, in parens:
vec(κ, N). The other four use the ordinary parameterised-kind form:Deque(κ, N),Map(K, V, N),Set(K, N),Tree(κ, N). No new grammar is involved: these are the existingIDENT(args)kind expressions, the same shape asosc(0, 100)ordecimal(18, 2). - In value position the construction namespaces are capitalised:
Vec.of,Map.of,Set.of,Deque.empty,Tree.node. The function namespacevec.*(map,fold,sortBy,topK, …) is unchanged and still lower-case.
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 toVec (§ The uniform
API), which is why the per-container API stays small — each kind carries only its structural
operations, and the rest of the algebra is reached through the bridge.
#Deque — queue, stack and ring buffer, in one kind
One structure for every access discipline. The representation is a ring — ⟨buf: vec(κ, N), head, len⟩ — so at(i) is O(1) as well as both ends:
| Discipline | Push | Pop |
|---|---|---|
| queue (FIFO) | pushBack |
popFront |
| stack (LIFO) | pushBack |
popBack |
| deque | either end | either end |
pushFront / pushBack / popFront / popBack yield the deque without the element; the
element itself is read with peekFront / peekBack, which are na on an empty deque. Splitting
read from removal is what keeps every operation returning exactly one value.
fluxdef rolling(q, x) = q.popFront().pushBack(x) // fixed-size ring: one out, then one in
Why
popFrontcomes 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: theLatest(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:
- Full ⇒ rejection and a diagnostic. Never silent growth.
isFulllets you decide first. - Absent ⇒
na. A missing key, an empty peek, an out-of-range index —na, never an exception.naisna-aware through the rest of the algebra, so it propagates instead of trapping. - No shortening operation, ever. There is no
filter: a data-dependent length would break the bound.whereandmaskare length-preserving and leavenain the holes, and iteration isna-aware, so nothing needs compacting. - 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:
-
Capacity is a compile-time const: a literal (
Map(price, num, 64)), a named const (MAX_BARS), or a const supplied by the host context — a chart's maximum bar count, a declared watchlist size. This is the pattern the series kind already uses:flux
type Series(T) = vec(T, MAX_BARS) // the context fixes the ceiling -
Occupancy is a run-time value, always ≤ capacity.
countmoves;Ndoes not. -
Capacity propagates through conversions.
vec(κ, M).toSet()is aSet(κ, M): deduplication can only shrink the population, soMis the safe bound — inferred, never annotated. -
A run-time ceiling is forbidden. A capacity that varies with the data would make the compile-time memory account unprovable.
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:
- Flux keys are almost always ordered kinds already (
string,num,decimal), so nothing is lost. - Sorted iteration is deterministic by construction. There is no seed to pin, no insertion-history to reproduce, no divergence to chase.
- It is usually the order you wanted anyway — rankings, per-key display.
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:
- In the ANALYSIS plane they are bounded and deterministic — a
Mapof per-asset moving averages, aSetof detected levels, aTreeof market structure. - In the APP plane they are Model fields. The Model's slotmap already is one, and the stable-id map beside it is the other:
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:
- No unbounded collection. The bound is always a const. Totality has no exceptions.
- No recursive type (
[ErrTotalType]) — trees and graphs go through a node pool and handle indices, never a type that points at itself. - No
filter— a data-dependent length breaks the memory account.where/maskare length-preserving. - Keys must be orderable and equatable (
[CmpOrd]/[CmpEq]) — noclock,uior lambda key. - No run-time-variable ceiling.
- Post-v1. Deferred, each with a reason and a trigger: a
Heap/ priority queue (topKcovers the need; the heap ships with the algorithms that require it), a pinned-hash backing (sorted-by-default suffices; the decision is evidence-based), a finger-treeDequewith O(log N) split and concat (the ring's O(1) ends suffice), and row polymorphism over the value kinds (v1 is monomorphic per site).
#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
- compute — the columnar
Table/Col/Matfamily, and theCol ↔ Vecbridge. - Kinds — kind parameterisation, finite height, and the
[CmpOrd]/[CmpEq]classes. - text — the Markdown AST, the named consumer of
Tree. - App plane — the slotmap, which is a
Map(Handle, V, N). - Memory model — the bounded arena, liveness, and in-place execution.
- net —
Latest(n), the bounded queue whose eviction policy is part of its kind.
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:
- Time symbols exist only as rate components.
meas[m·s⁻¹]is legal; a standalonemeas[s]is not, and there is no constructor for one. A duration has exactly one home in Flux — thedurationkind — and the algebra cannot strand a second one. You obtain one as a duration literal (7200s,500ms) or as a difference of times (time - time[n]); you then reach the unit world through the time bridge below, never through a fabricated time constructor. - Derived symbols declare their family as a power of another. A litre is length³ with an
exact factor to the cubic metre; a hectare is length² with an exact factor to the square
metre. So
meas[L]andmeas[m³]are two tags of one convertible family, and×/÷unify them exactly askmandmunify.
#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
- Kinds — the tag mechanism, and the affine substrate this reuses.
- Operators — the dimensional algebra
meas[u]plugs into. - asset & currency — the sibling tag axis, for instruments and money.
- compute — measurements in columns, and the
metric[id]sibling annotation. - i18n — formatting a quantity for a locale without making the number locale-dependent.
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
P² 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
- Kinds — the tag axes and their two regimes.
- Operators — the
±,×,÷rules and the FX role rule in full. - units — the sibling tag axis, for physical quantities.
- compute — asset-tagged columns, and joins that cannot mix instruments.
- Host integration — cross-series compilation and the as-of contract.
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
lento 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 astringAPI that means two different things. Sostr.*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
f64renders 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
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
filterthat 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:
- 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.
- 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 asDecodeError(field, reason)— never a silentnathat 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
- Kinds — the
stringsort, bit-equality, and why there is no ordering. - Lexical structure — string literals and the interpolation tokens.
- collections —
Tree,MapandSet: the node pool and the indexes this pillar composes on. - i18n — locale-aware rendering, plural selection, and the only string ordering that exists.
- net — the codec catalogue, and schema-typed decoding at the boundary.
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
appblock (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
dayOfWeekfor 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.
- The application declares keys. The host holds the catalogue: a table
(appId, locale) → { key → message }, authored alongside the app and delivered through the asset-bundle mechanism. Catalogues therefore version with the app, cache offline, and never enter the script as bytes. - The capability
i18n:cataloguegrants two things:t(key: string, args: record) -> string, resolved host-side (selection and formatting), andSub OnLocale(C).
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:
- Selection reads the pinned plural rules for the message's locale. The categories are
zero one two few many other; which of them a locale actually uses is the table's business, and the table has a version. - Formatting functions delegate to the pinned
fmt.*routines. There is one formatting source of truth in the whole system; MessageFormat never grows a second number or date formatter that could round differently from the first. - The message grammar is fixed — A12-conform, like every codec — and it is parsed host-side at catalogue load. A malformed message is a load-time diagnostic with a key-verbatim fallback, not a runtime surprise.
#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 astring. It does not changex, 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 andsortByon 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 astringis 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:
- Text runs render with UAX #9 bidi isolation. The table is owned and pinned by this pillar, version-locked alongside the segmentation tables in text.
- Interpolated values are isolate-wrapped by default. A user-supplied right-to-left string dropped into a left-to-right sentence otherwise reorders the punctuation around it — the trailing period jumps to the front of the line, the parentheses swap. It is one of the most reliably shipped bugs in the industry, and defaulting to isolation means an application cannot ship it.
uicontainers carry a logical-direction contract — start and end, rather than left and right — resolved by the host. There is no per-app mirroring code, and therefore no per-app mirroring bug.
#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
- text — the
stringkind, segmentation, and the pinned formatter this pillar extends. - App plane — subscriptions, capabilities, and the journal
OnLocalerides. - collections — the ordered
Map, and the absent-last stable orderingcoll.sortreuses. - display — the
uicatalogue, logical direction, and the output membrane. - units — rendering a quantity for a locale without the quantity becoming locale-dependent.
- Kinds — why
stringhas no ordering, and whatdiris.
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
Uint32Arrayand 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:
- the WCAG helpers —
color.relLuminance(c), which is the linearization the OKLab path already performs, exposed; andcolor.contrast(a, b) -> ratio, so an author can validate a palette's contrast at compile time rather than discovering it in an accessibility audit; - the compositing algebra —
color.over(a, b), source-over on straight alpha, plusmultiply,screenandoverlay, as pinned pure functions. The scene'sblendproperty names a host mode; this is the value-level algebra, for an author computing a colour rather than asking for one.
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:
- OKLab Cartesian, not OKLCH. The Cartesian form needs only
pow, which is pinned and available. The cylindrical form needsatan2and the sine-cosine pair, which are not — so choosing OKLab first is what lets perceptual blending ship now rather than behind a dependency. cbrtis realised as an odd function —cbrt(x) = sign(x) · pow(|x|, ⅓)— so a slightly out-of-gamut negative LMS value stays total instead of producing a NaN. Totality is not a property you get to suspend inside a colour routine.- The output is gamut-clamped to
[0, 255]per channel, with the rounding pinned (ties-to-even, exactly as the sinks round).
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:
- a
dirorsignalcolumn recolours the candles through the existing per-bar mechanism — theme and colour-vision-deficiency aware, because the mapping is the host's; - an explicit
colorcolumn (u32) drives a per-bar RGBA path — the candle body and wick, or a series; - a
plotwith a{ color: }channel colours each bar or segment of that series.
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 string — url(…), 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
- display — the scene, the
viz.*encoding channels, andpaint. - Kinds — where the
colorsort sits, and why it has no arithmetic. - compute — the pinned maths that the OKLab path routes through.
- Guarantees — I7, the oracle, and what "byte-identical" is worth.
- The four planes — the firewall this pillar is drawn against.
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
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:
- Tokens —
token.bull,token.bear,token.grid. Theme-aware, and the default for anything semantic. - Explicit colours —
rgb(r,g,b),rgba(r,g,b,a),hex("#RRGGBB"). Theme-blind, by the author's choice; for a scientific palette use the pinned scale constructors (color.seq,color.div,color.cat).
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 malformedhexyieldsnaand 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 data anchor plus a pixel offset (
dataSignal + 8px) is a composite coordinate constructor, not an arithmetic addition across sorts. The pixel part must be a const, so the position stays in the oracle. A screen-derived offset (screen.h * 0.05) would tip the whole position into stratum (b) — forbidden for an anchor a verdict reads. paintOrderis notz.paintOrder : numis 2-D painting order (who is in front of whom in the plane).z : depthis the depth coordinate, auto-normalized and projected. The kind decides which is which, so they cannot be confused.
#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 presentation — zero 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:
ease— an interpolation curve (linear,inOutCubic,outBack(s),cubicBezier(…),springCurve(k,d)), consumed by transitions, never arithmetic. It is a sort, not a variant, precisely so a script cannot decompose and re-parameterize a curve.shader<υ>— a host shader parameterized by the kind of its uniform record. The schema is the kind: the compiler checks the uniforms you pass againstυwith an ordinary call rule.
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.
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
- App plane — views, contributions, slots, and the PORT lifecycle.
- Canvas — signals, spaces, events and the frozen primitive set.
- Transitions — the transition descriptor and the morph boundary.
- compute — the
Tablethatviz.*consumes. - color — the
colorkind, OKLab interpolation and the pinned palettes. - Host integration — descriptors, registries, and the two breaches.
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
scanthat folds arrivals into closed bars, followed bytoSource, 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 counted — Dropped(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.Samplewould silently lose ticks between clock edges, andLatest(n)would lose them under a burst. So thebars(tf)fold that feeds the seam into analysis declaresback: 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:
net:fetch(domain)andnet:stream(domain)are granted per domain, with user consent, and enforced by the host's content-security policy. The script never holds the socket.- Authentication is host-held. The token never enters the script — the app receives an opaque handle, and the host attaches the credential. A leaked script cannot leak a credential it never had.
- Egress is governable. A supervisor can narrow (never widen) what an application may reach; a revocation is journaled as a bound, so a re-fold reproduces it deterministically and a command issued after it fails closed.
- Offline is a first-class state. A per-grant cache policy, a bounded offline command queue
replayed under an idempotency key on reconnection, and
OnConnectivityedges — never a silent multi-writer merge, which is a named non-goal.
#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
- App plane — commands, subscriptions, the journal, capabilities.
- Host services — the resource-handle doctrine this pillar follows.
- server — the other side of the wire, when it is ours.
- text — why there is no regular expression, and what replaces it.
- Host integration —
data:sourceand the causal ingestion contract. - The four planes — the firewall this pillar routes around.
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
stringwith 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 amsgcarrying a new handle or metadata — never content.
#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 buffer — buf(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:
- Handles are opaque. There is no verb that resolves one to content. Not filtered — absent from the catalogue. The classic exfiltration bug (read the file, post it to my server) needs a step that has no name.
- Handles are session-scoped. They do not survive a reload, which is why persisting a
document means persisting your own serialized state, never a retained operating-system
handle. A handle written into the persisted
docpartition compiles, and it is dead on the next launch — the mistake the scoping is designed to make cheap to find. - Every result is a message. A host op returns through the completion constructor it carried, so the whole chain is in the journal and replays exactly.
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:
- Intent goes out as an inert
Cmd, under a default-denynamespace:verbcapability. The command is data — a handle key, a name, a template id, a quantity. It carries no resource. - The outcome comes back as a journaled
msg, through the completion constructor the command carried. There is no callback, no promise, noawait. - 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.
- Revocation rides the membrane. A grant dropped mid-session writes a
CapRevokedbound 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:*
Cmd ClipWrite(text)— text in v1; images by handle later.Cmd ClipRead(C)→msg(string).
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.
- The payload does not become a Model. It becomes a message, at the head of a fresh journal.
initstill runs first;updatestill folds; the journal is still the sole source of truth.- A cold start is therefore replayable like any other run: re-folding the journal reproduces the launch exactly, because the launch is in the journal.
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
paramspassed 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
- App plane — the capability model, commands, subscriptions, the journal.
- net — the socket the host holds, transfers and progress, the offline queue.
- server — push fan-out, receipt validation, and shared storage.
- display — the view primitives,
toast, slots and the sanitizer these capabilities render through. - text — declared codecs and the editing protocol a paste routes through.
- Guarantees — what capability security actually promises, and how it is checked.
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
updateis 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,Deniedtells you the tier refused you,QuotaExceededtells you the truth about the quota, andConflict(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:
- The seed is server-derived. Randomness enters an application through
OnRand(seed), and the seed is minted by the server, not the client. A player cannot search for a favourable seed, because they did not choose it. - Elapsed time is host-authoritative. The clock the run was scored against is not a number the client reported.
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:
- Default-deny capabilities, aggregated transitively and capped by the grant:
manifest = ( ⋃ emit Cap over the transitive closure ) ⊓ the grant. A server row is a row like any other. - The manifest is inspectable before anything is deployed, and it is derived by the compiler from the emit sites — its only origin.
- Quotas and metering per application: CPU time, rows, egress. The accounting hooks that the network pillar defined for metered feeds find their billing consumer here — a cap reached is a circuit breaker, not a surprise invoice.
- Audit. The immutable host audit log gains a hash-chained server mirror, so governance across a fleet reads one journal discipline rather than two.
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
- App plane — the TEA core, the journal, capabilities, and the two named Model-producer exceptions.
- host services — the client half: payments, notifications, and the resource-handle doctrine.
- net — typed ingress, egress grants, and the offline queue on the other side of the wire.
- Guarantees — what determinism promises, and the machine checks behind each promise.
- Compiler & runtime — the I7 gate, pinned routines, and reproducible builds.
- Packages — the lockfile and the dependency closure the deploy gate pins.
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 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 oracle is the interpreter, evaluating the graph directly.
- The candidate is the instantiated WebAssembly module.
- They are compared byte-wise on every sink column, over a hostile, deterministic, adaptive corpus — series longer than the program's resolved maximum lookback, seeded with holes, ±infinity, negative zero, raw non-canonical NaN patterns, exact half-integers, flat runs, monotone runs, magnitudes at 1e±9 — first in batch, then live (bar by bar, through the incremental step), then on the real data if any is supplied.
- Any divergence blocks the compilation. The module is not shipped; the interpreter keeps serving.
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
- Engine swaps are invisible. Serving interpreter results now and WASM results a moment later produces the same bytes. No migration state, no reconciliation, no repaint, no golden churn.
- The optimizer needs no trust. Any value change it introduces is caught by the blocking gate at the compilation that produced it.
- A distributed module is verifiable. Anyone holding the graph can re-run the validation locally: identity is checkable, not promised.
#The browser path, as built
The main thread never imports the compiler. It orchestrates:
- A registered script serves immediately, on the interpreter — the proven path, with no compile step between typing and seeing.
- 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.
- 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.nearest — ties 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,DateandIntlare 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:
- the optimization pipeline is fixed (an
O3pipeline, no shrink bias, fast-math off, a fixed feature set) and re-validated by the gate at every compile; - the Binaryen version is an input to the build hash, not just a note in a changelog;
- iteration is independent of pointer or address order; function and local ordering is canonical; the name and producer sections are omitted or pinned; no timestamp, path or build id enters the artifact.
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:
- 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.
- 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".
- Application panes. One execution and distribution format for indicators, representations, drawings, scenes, transitions and application logic.
- 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 |
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
- Memory model — the layout the plan produces, and why the plan itself is pinned.
- Optimizer — the correctness law, the tiers, and translation validation.
- Concurrency — the scheduler, and the 1 ≡ N proof the stress suite exercises.
- Packages — content addressing, the lockfile, and what the rebuild gate seals.
- Guarantees — the same properties, stated for the reader who must trust them.
- Inference — why deterministic inference is a prerequisite for all of this.
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:
- Per-bar values live in machine registers (WASM locals, the interpreter's stack). There, a NaN may carry any payload — no operation in the instruction set observes one.
- At every observable boundary — a sink column write, a snapshot, a hash, a serialization —
the value is forced to the single canonical quiet NaN
0x7FF8000000000000. In the WASM backend this is ani64.storeof the bit-exact twin of the interpreter's canonicalizing store.
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 annaappears, 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.
Two consequences worth naming:
- The hot loop allocates nothing. Every buffer is allocated once, from the graph, before the first bar. There is no allocation, no free, and no fragmentation while stepping.
- Most ring positions are derived; two families keep a cursor. A windowed kernel holds its
history in a ring of its period
p. For the cursorless kernels — the sliding sums (sma,sum,stdev,bollinger), the pure ring-scans (wma,cci,change,stderr,percentrank) and the weighted windows (winw,alma) — the slot for barkisk mod p, read straight off the bar counter, and there is no write index to persist because there is none to keep. Two families are genuinely different, and the difference is exactly the state a checkpoint has to carry:rsirings the variations, not the bars. It pushes one only on a bar whose predecessor is finite, so its write cursor counts pushes, not bars — a hole in the data advanceskand leaves the cursor where it was. It is therefore not a function ofk, and it is persisted, alongside the running averages.- The monotone deques —
highest,lowest,aroonup,aroondown— persist a head and a tail. The ring positions are derived from those two counters; the counters themselves are state, written back on every bar.
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:
[ 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 = maxmemory, 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.
The pass has three steps:
- 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). - 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. - 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
- Compiler and runtime — the pipeline, the byte-identity gate, and the pinned routines.
- Optimizer — the passes that shape the graph the plan is computed from.
- Concurrency — the scheduler, shared memory, and the 1 ≡ N proof.
- Kinds — where
na,decimal,stringandveccapacities come from. - collections — the bounded arena structures.
- App plane — bounded Models, slotmaps, journals and checkpoints.
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 product is silently correct. A user never sees a wrong number; at worst they get the unoptimized path, which is the path the value was defined by anyway.
- The test suite treats any such diagnostic as a failure. It is loud in continuous integration and invisible in the product — which is exactly the right way round.
#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:
- Bit-exact in IEEE-754
f64for every input — including thenapaths, signed zeros and infinities. No floating-point reassociation, ever. - 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.
- 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.
- The ABI is untouchable. A rule may never eliminate, merge, retype or reorder an
inputnode — the parameter block is a contract with the host, and an optimizer that "helpfully" dropped an unused knob would break every saved chart. - 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
−0arises constantly in real data (a difference that rounds to zero from below), it compares equal to+0, and it prints as0— 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 host declares the set. Which scripts are co-active is an explicit declaration, not an inference from what happens to be alive. The set has a canonical key (its members, sorted), so the order they were opened in is unobservable and the same set reuses the same module.
- Knobs never merge.
inputnodes are excluded from elimination across the boundary, so two scripts that both expose a "period" knob keep two independent settings. The graph is shared; the parameter block is not. - The oracle stays per script. The gate does not compare an evaluation of the merged graph. It compares each script's own unoptimized evaluation against that script's slice of the merged module's sinks — batch, live, mixed-seed, bit-exact. That is the stronger check, and it is the cheaper one: a divergence names the guilty script instead of pointing at a graph nobody wrote.
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 tosum(x, p) ÷ p— unconditionally, and not because a division is cheaper. It is a normalization: it makes ansmaand asumover 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 search space is already exhausted. The shipped rules are orthogonal — their left-hand sides are structurally disjoint and left-linear — which makes the rewrite system confluent, and a confluent system's direct fixpoint reaches the unique normal form. There is no better form for an e-graph to find. This is not an accident of the current table: it is forced by the correctness law. The rewrite classes where saturation actually shines — reassociation, commutativity, distribution — are exactly the classes the bit-exactness law forbids, because they change IEEE rounding. A rule table that may not be generative is a rule table an e-graph cannot help.
- The one place extraction could differ is a phantom. The
sma → sum ÷ pcanonicalization above looks like the case for cost-driven extraction: for a lonesma, the static cost model scoressum ÷ phigher, so an e-graph would extract thesmaform back. It would gain nothing. The emitter lowers both forms to the same instruction sequence, and thesmaform carries an extranaguard on top — so the "cheaper" extraction is marginally bigger, and the measured difference at fifty thousand bars is under one percent. Extraction would have optimized an artifact of the cost model, not the program.
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 identities —
x·1 → x,x÷1 → x,−(−x) → x. Multiplying or dividing by exactly1.0returnsxuntouched, and negation flips a sign bit, so twice is the identity —±0,±∞andnaincluded. - Strength reduction — a division by a power of two,
x ÷ 2^k, becomes a multiply by its reciprocal,x · 2^-k. Division is the expensive instruction; a power of two and its reciprocal are both exact, so the multiply lands on the same bits. A÷3is left alone, because1/3is not representable. (WAT below.) - Constant folding — an arithmetic, comparison or logic operation over constants collapses to
one constant, through the same
f64operations the interpreter and the module run. Transcendentals fold too:log,exp,sin…powover a constant are evaluated at compile time by the same pinned libm the run uses, so the folded bits are the bits a bar would have produced. - Boolean-domain peepholes —
a < bunder a negation becomesa ≥ b; a double negation¬¬bbecomesb; and the everydayif b then 1 else 0becomesb. Each holds only where the compiler has proved the value is0,1orna— the output of a comparison, a logic op or a crossing. The proof is the guard: off that domain,¬¬5is1, not5. - Kernel normalization —
sma(x, p) → sum(x, p) ÷ p. Not a speed rewrite: the normalization discussed under the cross-script merge above, the one that makes ansmaand asumover the same source and period the same node, so they can share one ring.
The passes with no rule table — they are the machine.
- Common-subexpression elimination is the rebuilder. Every pass reconstructs the graph through one structural key and hash-conses as it goes, so an identical sub-expression is one node however many times it was written. There is nothing to run: the rebuild is the merge. (WAT below.)
- Dead code is swept. A node no sink reaches is never emitted, and the memory plan is re-derived from what remains.
- The schedule is nudged to help the memory plan. After rewriting, liveness (the A13 plan) is recomputed, and the ready nodes are reordered greedily so each is emitted when it frees the most slots — never-worse: if the peak would not drop, the original order stands and no bytes churn. Buffers with disjoint lifetimes then share a slot. See memory model.
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:
- A pack is never sealed from a fallback. If the optimizer was discarded during a build, the build refuses to seal rather than shipping the unoptimized artifact. Otherwise a verifier — whose own gate would discard the optimizer on the same divergence — would recompile to different bytes and accuse an honest pack of lying. A discarded optimizer at build time is a bug to fix, not to ship around.
- A hash mismatch is diagnosed, not just reported. When a verification finds different bytes, it also recompiles without the optimizer and compares against that, so it can say "built by an older or unoptimized toolchain" instead of the useless "this pack does not match its source".
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:
- A bare
rsi(close, 14)has nothing to optimize. It already is the native kernel. - The gain grows with the complexity of the script and with the number of co-active scripts — which is where the redundancy actually lives.
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
- Compiler and runtime — the gate this law rides on, and the pinned routines.
- Memory model — the liveness plan, which is itself a T0–T1 pass.
- Concurrency — the scheduler, and why parallelism cannot change a value.
- compute — the fusion and the pushdowns, at the dataframe level.
- Guarantees — what "verified at every compilation" means for a reader who must trust it.
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:
- Shared, read-only: the input columns. The host writes the bar columns — time, open, high, low, close, volume — once, into a single shared array buffer. Every worker reads them from there. Nothing is serialized through a message, and a few megabytes of history cost a copy measured in fractions of a millisecond rather than a structured-clone round trip.
- Owned, write: everything a task produces. Each task runs its own compiled module instance, with its own linear memory, owned by the worker executing it. Result columns come back by buffer transfer — zero-copy, and the sender loses the buffer as it hands it over — never by sharing.
Two rules keep that honest:
- No atomic ever touches data. Atomics serve only the scheduler: the task-claim cursor, the done counters, the epoch — in a small dedicated buffer of their own.
- Nothing writable is shared. Not "shared with a discipline". Not shared at all. Which is a stronger property than the one the contract asks for, and it is the reason the per-worker arena rule below holds by construction rather than by review.
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:
- scripts that share a sub-expression are glued into one component by the shared node — which is exactly right, because shared state must never be split across two workers;
- scripts that share nothing fall apart into independent components and spread across workers;
- a sink binds its dependencies into its component: a task owns every column it produces.
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.
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:
- The scratch arena is per worker. Private, sized to the peak of that worker's sub-graph. Inter-worker slot aliasing becomes impossible by construction, not merely improbable.
- Every producer → consumer handoff carries a happens-before edge — a release-store of the ready counter, an acquire-load, or a notify/wait — even in the single-writer, single-reader case. "Zero aliasing implies no race" is necessary but not sufficient under the JavaScript and WebAssembly memory models: without the edge, the consumer is not guaranteed to see the producer's write at all.
#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:
- the graph is pure, so values are schedule-independent;
- the partition never splits the interior of a reduction — nor, in v1, the interior of anything else: a task is a whole component, and a component is never cut;
- the pairwise reduction tree is frozen, and the reference graph adopts the same tree — so parallel and sequential agree by construction rather than by accident. The clause is inert today, and binds the moment the reserved class has a member;
- ordering is canonical on the value of a key, never on the order a worker happened to see it;
- the random generator is counter-based and therefore position-independent;
- the arenas are per worker.
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
- byte-equality of the output, and
- zero concurrent slot writes.
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:
- Absent the headers, N = 1, automatically. No fleet, no shared buffer, no degraded mode to test: the ordinary single-module path is the N = 1 case, so a visitor without cross-origin isolation runs the same graph, gets the same bytes, and pays only in latency. There is no second code path to keep correct, which is the only reason this fallback can be trusted.
- The fleet is used only when it pays. Even with the headers, the scheduler takes the fleet path only when there is more than one core and the merged graph's measured cost clears a threshold. Below it, the merged single-module path is faster, and it is what runs.
- A hung task is a timeout, not a hang. A worker that never comes back cannot be interrupted, so the batch carries a deadline derived from its own cost estimate. On expiry the fleet is torn down and respawned, and the batch is replayed. Purity is what makes that safe: a replayed batch produces byte-identical results, so a retry is not a second answer — it is the same answer, arrived at again. Any fleet error at all falls back to the single-module path for that tick: loud in the statistics, invisible to the user.
#Budgets across scripts
A per-script node budget is not enough when a chart carries several scripts. Two more bounds apply:
- a cap on co-active scripts merged into one graph, and
- an aggregate frame budget.
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
- Memory model — the liveness plan, and why the arena must be per worker.
- Optimizer — the shared cost model, and the frozen reduction order.
- Compiler and runtime — the gate, and the harness this stress suite belongs to.
- compute — where the parallelism actually pays: independent groups, independent cells.
- Guarantees — 1 ≡ N stated for the reader who has to rely on it.
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:
- 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. depth/z is a first-class coordinate, not a 3-D feature.- The plane split and the descriptors are compilation targets, not conventions.
- Registries accept script-registered entries in the same shape as built-ins.
#The five descriptors
#① 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
}
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)
- Referencing —
series(key)returns a record whose columns carry the asset tag derived from the host's metadata:open/high/low/close : price[base, quote],volume : volume[base]. The key is a host-allowlisted resolution key, distinct from the tag it yields. - Alignment is as-of, and causal — the foreign series is aligned onto the chart's ordinal
axis by taking the most recent foreign bar with timestamp ≤ the current bar's time (the
same floor-containing rule as I2). No future bar is ever visible; a gap holds the last known
value; before the first foreign bar the value is
na. No-repaint is inherited, not re-argued. - Kinds make it safe —
price[BTC,USD] + price[ETH,USD]is[ErrDim], and so isprice[BTC,USD] + price[BTC,EUR]. A dollar is not a euro (asset & currency). - Compilation — the graph declares its asset dependencies; the host fetches and aligns them and hands them to the engine as additional input columns. More inputs; no new substrate.
#Reserved extension seams
Every future capability enters through one of two doors, which is what keeps the analysis core untouched:
- Input — every signal from the world (a key, a pointer, a tick, a price, a pick) becomes either a readable stream or an event, through a single ingestion point. Replay and determinism stay uniform because everything is journaled.
- Output — every heavy render or computation is executed by the host under a capability, on the presentation side, contained by the firewall. GPU and wall-clock non-determinism never propagates into the core.
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
- Kinds — why
boxmust be alevel, and how the lattice forces the physics. - Time and state — clocks,
@, and the floor-containing rule (I2). - App plane — contributions, slots, and the layout boundary.
- display — scenes, panes, the draw-list chain, the 3-D model.
- Compiler and runtime — I6 and I7, and what "byte-identical" is checked against.
- Packages — how a third-party representation is distributed and pinned.
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:
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:
- A typed ABI. The
pubentries and their monomorphic kinds are the contract. Its version lives in a hash-pinned interface window — the same idiom a codec uses for its accepted schema range. - Per-module provenance. Each dependency carries its own trust tier and its own hash.
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:
- 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.
- No dependency can exceed what the user granted. Authority flows only along import edges, capped by the grant.
- 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.
#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".
opengives you both.closedgives 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:
- entries are stored, never compressed — deflate output differs across library builds, and a compressed archive would make the pack hash depend on which zlib the author linked;
- entry order is the sorted canonical path; timestamps are a fixed epoch; permissions are fixed; there are no extra fields and no comments.
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
- Compiler and runtime — the rebuild gate, and the pinned routines a lock includes.
- App plane — capabilities, the sealed manifest, the two trust tiers.
- FDK overview — the prelude, and where modules fit.
- Guarantees — what reproducibility actually promises.
- Host integration — registries, and how a third-party representation is served.
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 grains — step (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
update — assert 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
- Getting started — the guided version of the first few recipes.
- FDK overview — the namespaces these recipes draw on.
- Kinds — why the rejected examples are rejected.
- Canvas — the signal algebra behind the moving ones.
- App plane — the full contract behind the application.
- display —
viz.*, for data that is not a price series.
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:
- Presentation is not deterministic, and does not try to be. The GPU, the compositor and unseeded randomness are outside the oracle — contained by the firewall, never eliminated.
- Replay proves coherence, not truthfulness. A host-pushed payload journaled as data — a pick result, a pre-computed outcome — is re-folded verbatim; replay does not re-run the ray-cast to attest it. A score that depends on such an outcome needs the server to re-derive it, or must be excluded from a shared leaderboard. This is a named open problem, not a hidden one.
- Post-v1. Server re-execution is designed, not shipped. The determinism that would let a server re-run a client's work and catch a forged result is real, and verified — but in v1 it is verified on the client. The server leg waits on the server port of the grader.
- Bounds are claims, not invariants.
osc(0,100)says what a value conventionally is, not what it provably is. Onlyclampmakes it real. - The sandbox rests on two pieces of ordinary code — the capability monitor and the view sanitizer. Everything else is safe by construction; those two are safe by review, by model checking, and by fuzzing.
#See also
- Design pillars — the same properties, from the design side.
- Compiler and runtime — the gate and the pinned routines.
- Optimizer — the correctness law, and the rewrites that look sound and are not.
- Concurrency — why parallelism cannot change a value.
- App plane — capabilities, journals, and replay.
- FAQ — the questions this page provokes.
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:
- Dimensional explanations. A kind mismatch is explained in terms of what the values are, never in terms of the type checker's internals.
- Did-you-mean. An unknown name, a missing field, a misspelled kernel — matched by edit distance against the names in scope, the catalogue, and the record's fields (all of which are already materialized for completion).
- "You forgot
plot". A bare expression at the top level is a syntax error — the grammar has no expression-statements, and that stays frozen. But when the expression's kind is presentable, the editor recovers it pedagogically: "to show this, wrap it inplot", with the quick-fix. The language stays strict; the experience does not.
#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
- Inference — the typable cone, and why a broken line does not blank the preview.
- Kinds — what kind-filtered completion is filtering on.
- Getting started — the editor as you first meet it.
- Guarantees — what the status chip is actually asserting.
- FDK overview — doc-as-data, and the completeness lint.
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.
#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.
#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).
#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, …).
#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.
#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.
#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.
#"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
- Guarantees — each promise, and its machine check.
- What is Flux — the mental model, and the non-goals.
- Kinds — the type system behind half the answers above.
- The four planes — the firewall behind the other half.
- Glossary — every term used here, defined.
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
- Kinds · Inference — the type system in full.
- The four planes — the plane vocabulary in context.
- Guarantees — the invariants, stated for a reader who must trust them.
- FAQ — the questions these definitions provoke.
- FDK overview — the namespaces and the capability model.