◆ Flux

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 page 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.

New here? Start with Guide §5 — Kinds: types that carry meaning →

Two systems, one error channel

Shared, not drift — this section and The affine substrate below are the canonical statement of the two-systems / affine split; Operators carries the one-paragraph echo and links back here.

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

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

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

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

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

FLUX
fast   = 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:

FLUX
fast = volume > sma(volume, 20)

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

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

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

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

The affine substrate

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

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

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

FLUX
gap    = 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:

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:

The structural sorts — vec and record

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

The APP-plane sorts — variant and ui

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

No function sort

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

The kind catalogue

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

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

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

The partial order

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

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

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

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

Two tiers of coercion edges

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

Antisymmetry survives because both tiers rise strictly and never overlap.

Literal polymorphism

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

FLUX
hot   = 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)].

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

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

Join and meet

The join catalogue

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

Two placements deserve their reasons:

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

FLUX
c = 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:

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

Orthogonal tags

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

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

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

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

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

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

Numeric representation — f64 and decimal(scale)

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

FLUX
exact = 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:

FLUX
age    = 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).

FLUX
c      = 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:

FLUX
c         = 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:

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

Named records and variants

Structural kinds can be declared and named at program level:

FLUX
record 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:

FLUX
record 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:

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

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

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

Errors and na at the kind level

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

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

FLUX
m   = 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