Turmeric's stdlib/arrow.tur provides a small set of bare-function arrow
combinators over the (->) function arrow. They describe composable
computations that take an input and produce an output -- arr, >>>,
arrow-first, arrow-second, par-comp, arrow-split, arrow-const, and
arrow-dup.
The most immediate use case in Turmeric is signal processing: the
stdlib/signal/ libraries build on these combinators to create composable
DSP graphs from pure building blocks.
Turmeric ships the arrow API as two surfaces in one module, stdlib/arrow.tur:
| Surface | When to reach for it |
|---|---|
| Bare functions | The simple, default path. You are working concretely with the function arrow (->) and do not need to be polymorphic over the arrow constructor. |
| Typeclass dispatch | You want code parametric over any Arrow instance, resolved through the instance dictionary -- the Haskell-style Arrow / ArrowChoice / ArrowLoop / ArrowApply hierarchy. |
The bare layer exposes plain functions: arr, >>>, arrow-first,
arrow-second, par-comp, arrow-split, arrow-const, arrow-dup. The
typeclass layer declares the classes and instantiates them at (->) with the
canonical method names (arr, >>>, <<<, first, second, left,
right, +++, |||, app).
Both surfaces live in the same module and share the names arr / >>>. A
bare call dispatches to the matching instance when the receiver's type selects
one, and falls back to the bare combinator otherwise -- so a single (load
"stdlib/arrow.tur") gives you both. (This unification became possible once a
free defn and a typeclass method of the same name were allowed to coexist;
see
docs/reported/typeclass-methods-share-value-namespace-with-defns.md.)
For the function arrow, arr lifts a function (the identity up to eta), and
>>> is left-to-right composition; both surfaces compute identical values --
see tests/fixtures/arrow-instance-vs-bare.
(import stdlib/arrow.tur)
;; arr: lift a function (identity for the function Arrow)
(let [add1 (arr (fn [x] (+ x 1)))
double (arr (fn [x] (* x 2)))]
;; >>> : compose left-to-right: add1 first, then double
(let [pipeline (>>> add1 double)]
(println (pipeline 5)))) ; => 12
The dataflow reads left-to-right: input enters add1, the result flows into
double, and the final value exits.
Any correct Arrow instance must satisfy:
arr id >>> f = f ; identity
arr (g . f) = arr f >>> arr g ; composition
(f >>> g) >>> h = f >>> (g >>> h) ; associativity
first (arr f) = arr (first f) ; naturality
first (f >>> g) = first f >>> first g ; product distribution
Turmeric does not enforce these at compile time, but violating them produces pipelines with unexpected behaviour.
first and second let you route a branch of a Pair through an arrow while
passing the other branch unchanged.
(import stdlib/arrow.tur)
(let [add1 (arr (fn [x] (+ x 1)))
first-add1 (arrow-first add1) ; apply add1 to fst, pass snd through
p (Pair 5 10)]
(println (first-add1 p))) ; => Pair(6, 10)
arrow-first and arrow-second are the plain-function helpers exported from
stdlib/arrow.tur. The bare layer exports only forward composition (>>>);
write (>>> f g) with the arguments in the order you want them applied. If you
need reverse composition (<<<), reach for the typeclass layer below -- the A3
operator-mangling fix gives >>> and <<< distinct C identifiers
(_gt_gt_gt / _lt_lt_lt), so they coexist there as method names.
stdlib/arrow.tur exports several convenience combinators:
par-comp (parallel composition)Apply f to the first component of a Pair and g to the second simultaneously.
(let [both (par-comp 0 0 0 0 (fn [x] (+ x 1)) (fn [x] (* x 2)))]
(both (Pair 3 4))) ; => Pair(4, 8)
Note: the first four 0 arguments are type-level placeholders (the type
variables a b c d); they are ignored at runtime.
arrow-split (fanout)Duplicate a value and apply two functions, collecting results in a Pair.
(let [split (arrow-split 0 0 0 (fn [x] (+ x 1)) (fn [x] (* x 2)))]
(split 3)) ; => Pair(4, 6)
arrow-const and arrow-dup((arrow-const 42) 999) ; => 42 (ignores input)
(tuple2-1st (arrow-dup 7)) ; => 7 (Tuple2(7, 7))
stdlib/arrow.tur)When you want to write code that is parametric over the arrow constructor --
not hard-wired to (->) -- use the typeclass layer. It declares the
Haskell-style hierarchy and instantiates it at the function arrow:
| Class | Methods | (->) instance? |
|---|---|---|
Category |
ident, comp |
yes |
Arrow |
arr, >>>, <<<, first, second |
yes |
ArrowChoice |
left, right, +++, \|\|\| |
yes (over Either) |
ArrowLoop |
arrow-loop |
yes (non-recursive subset) |
ArrowApply |
app |
yes |
ArrowZero |
zero-arrow |
no -- (->) has no zero (see Kleisli below) |
ArrowPlus |
plus-arrow |
no -- declared for other arrows |
Every call resolves through the instance dictionary rather than a free function:
(load "stdlib/arrow.tur")
(defn add1 [x : int] : int (+ x 1))
(defn mul2 [x : int] : int (* x 2))
(defn main [] : int
(let [h (>>> add1 mul2)] ; >>> dispatches to Arrow [(->)]
(println (h 3))) ; => 8
(let [g (<<< add1 mul2)] ; reverse composition, only on this surface
(println (g 3))) ; => 7 (add1(mul2(3)))
0)
arr is eta-expanded in the instance ((fn [x] (f x)), not the bare f) so
the dispatched result carries a concrete arrow type and is directly callable;
see docs/reported/instance-method-returning-untyped-param-loses-result-type.md.
Category -- identity and compositionCategory is the base of the hierarchy: an identity arrow ident and forward
composition comp (comp f g runs f, then g). comp is a distinct method
name from Arrow's >>>, so the two coexist without an operator collision.
ident is nullary -- there is no argument to dispatch on, so it resolves by
return-type / unique-instance dispatch (the mechanism from
return-type-dispatch-nullary-arrow-methods-plan).
With only the (->) instance in scope, a bare (ident) resolves uniquely:
(load "stdlib/arrow.tur")
(defn add1 [x : int] : int (+ x 1))
(defn main [] : int
(let [i (ident)] (println (i 41))) ; identity arrow => 41
(let [h (comp (ident) add1)] (println (h 41))) ; left identity => 42
(let [h (comp add1 (ident))] (println (h 41))) ; right identity => 42
0)
Once a second Category instance is in scope (e.g. Kleisli below), a bare
(ident) is ambiguous -- ascribe it to pick the head: (:: (ident) :Kleisli).
ArrowZero honestly: the Kleisli arrow (stdlib/kleisli.tur)The (->) arrow has no ArrowZero instance because a total function a -> b
with no input cannot conjure an inhabitant of b. The honest home for
zero-arrow is an arrow whose codomain has a zero. stdlib/kleisli.tur
provides one: Kleisli A B = A -> Option B, where none is the zero.
(load "stdlib/kleisli.tur")
(defn safe-recip [x : int] : int (if (= x 0) (none) (some (/ 100 x))))
(defn add1m [x : int] : int (some (+ x 1)))
(defn main [] : int
;; Category [Kleisli]: ident = \a -> some a; comp threads Option-bind
(let [fg (:: (comp (kleisli safe-recip) (kleisli add1m)) :Kleisli)
r (k-apply fg 5)]
(println (unwrap-or r -1))) ; (100/5)+1 => 21
;; none short-circuits composition
(let [fg (:: (comp (kleisli safe-recip) (kleisli add1m)) :Kleisli)
r (k-apply fg 0)]
(println (some? r))) ; safe-recip 0 -> none => false
;; ArrowZero [Kleisli]: the honest zero arrow, \_ -> none
(let [z (:: (zero-arrow) :Kleisli)]
(println (some? (k-apply z 99)))) ; => false
0)
Kleisli is the worked second Category instance the hierarchy needs; it is
also the reason ArrowZero stays declared-but-uninstantiated at (->).
Ascription-disambiguated arrows ((:: (ident) :Kleisli), etc.) are ordinary
unrestricted values and may be reused freely.
ArrowChoice over EitherArrowChoice routes an arrow over one arm of a binary sum. It builds on the
Either sum type (stdlib/either.tur, from
docs/archive/history/sum-types-either-plan.md);
left acts on Left and passes Right through, +++ fans two arrows over the
two arms, and ||| collapses both arms to a common result:
(load "stdlib/arrow.tur")
(defn add1 [x : int] : int (+ x 1))
(defn mul2 [x : int] : int (* x 2))
(defn main [] : int
(let [c (||| add1 mul2)]
(println (c (Left 10))) ; add1(10) => 11
(println (c (Right 7)))) ; mul2(7) => 14
0)
ArrowLoop -- non-recursive onlyarrow-loop feeds part of an arrow's output back as input. Turmeric is strict
and has no lazy thunks, so the (->) instance implements only the case where
the looped arrow does not read the fed-back component (it runs the arrow on
(b, sentinel) and projects the first output). True lazy feedback is future
work.
The dispatch surface computes the same values as the bare functions for (->)
-- tests/fixtures/arrow-instance-vs-bare runs the same pipeline both ways and
asserts identical output. The dispatch fixtures
(arrow-instance-stdlib-basic, -choice, -loop-nonrecursive, -apply,
-closure-capture) lock the typeclass path, including capturing closures
flowing through the instance dictionary.
Where the Signal/SF library lives: the worked example is the
tur-signalspice in../turmeric-spices/spices/signal/(seedocs/upcoming/tur-signal-rebuild-plan.mdfor the rebuild plan and current acceptance state). The code samples below use thestdlib/signal/core.turpaths from the original placement; treat them as the conceptual surface -- the same names are exported bysignal/core,signal/osc,signal/filter,signal/shaper,signal/envelope, andsignal/composein the spice. End-to-end exercise of the SF-application surface is currently gated on the reports underdocs/reported/nameddefmodule-loses-fat-fn-type- annotationandvec-typed-fat-closure-readback-fixture-regressed- codegen; the bare combinators (arr,>>>,arrow-first,arrow-second) work today.
stdlib/signal/core.tur introduces the Signal abstraction:
Signal a = Time -> a
SF a b = Signal a -> Signal b
A Signal is just a function from time to a value. A Signal Function (SF)
transforms one signal into another. Because SF is itself a function (Signal a ->
Signal b), the bare-function arrow combinators apply without any extra
machinery: arr, >>>, arrow-first, and arrow-second all work on SFs
out of the box.
(import stdlib/signal/core.tur)
;; constant: ignore time, always return the same value
(let [dc (constant 5.0)]
(dc 0.0) ; => 5.0
(dc 99.0)) ; => 5.0
;; time-signal: identity -- returns the time at each sample
(time-signal 2.5) ; => 2.5
;; sample: evaluate a signal at a point in time
(sample (constant 3.0) 1.0) ; => 3.0
;; map-signal: lift a function to pointwise operation over a signal
(let [louder (map-signal (fn [x] (* x 2.0)) (constant 0.5))]
(louder 0.0)) ; => 1.0
pair-signals zips two signals into a product signal, needed by combinators
like mix and add that take stereo or multi-channel input:
(let [prod (pair-signals (constant 1.0) (constant 2.0))]
(prod 0.0)) ; => Pair(1.0, 2.0)
stdlib/signal/dsp.tur provides ready-made oscillators, filters, and amplitude
operations, each as a Signal Function.
(import stdlib/signal/core.tur)
(import stdlib/signal/dsp.tur)
(let [dummy (constant ())
;; sine oscillator: freq Hz, initial phase in radians
a440 (sine 440.0 0.0)
;; square wave: freq, duty cycle [0,1]
sq (square 220.0 0.5)
;; sawtooth wave: freq
saw (sawtooth 110.0)
;; triangle wave (stdlib/signal/dsp.tur -- if available)
tri (triangle 55.0)]
;; An oscillator SF is called with a (usually dummy) input signal
;; and returns the output signal.
((a440 dummy) 0.001))
The oscillators ignore their input signal; the dummy argument satisfies the
SF interface so they compose uniformly with filters and other SFs.
;; low-pass: exponential moving average.
;; alpha in (0,1) -- lower alpha = more smoothing (shorter cutoff)
(let [lp (low-pass 0.1)
sig (constant 1.0)
out (lp sig)]
(out 0.0) ; starts at 0 (filter state is zero-initialised)
(out 0.001))
;; high-pass: subtract the low-passed signal from the input
(let [hp (high-pass 0.1)
out (hp sig)]
(out 0.001))
(let [sig ((sine 440.0 0.0) (constant ()))
;; gain: scale every sample by a constant
gained ((gain 0.5) sig)
;; mix: weighted blend of a pair signal
;; alpha=0 -> all first, alpha=1 -> all second
blended ((mix 0.3) (pair-signals sig (constant 0.0)))
;; add: sample-wise sum of a pair signal
summed ((add) (pair-signals sig sig))]
...)
Because SFs are plain functions, >>> wires them into graphs. Read each >>>
as "then": input flows left-to-right through each stage.
(import stdlib/signal/core.tur)
(import stdlib/signal/dsp.tur)
(let [dummy (constant ())
input ((sine 440.0 0.0) dummy)
;; Signal graph:
;; sine 440 Hz
;; -> gain 0.5
;; -> low-pass (alpha 0.1)
;; -> DC offset +0.1
;; -> clip [-0.8, 0.8]
chain (>>> (>>> (>>> (gain 0.5)
(low-pass 0.1))
(offset 0.1))
(clip -0.8 0.8))
output (chain input)]
(output 0.0)
(output 0.001)
(output 0.002))
pair-signalsRoute a signal down two parallel branches and mix the results:
(let [dummy (constant ())
raw ((sine 440.0 0.0) dummy)
;; Branch A: full signal
branch-a raw
;; Branch B: low-passed version
branch-b ((low-pass 0.05) raw)
;; Mix 50/50
output ((mix 0.5) (pair-signals branch-a branch-b))]
(output 0.001))
(let [dummy (constant ())
a440 ((sine 440.0 0.0) dummy) ; root
e660 ((sine 660.0 0.0) dummy) ; perfect fifth
summed ((add) (pair-signals a440 e660))
out ((gain 0.5) summed)] ; normalise
(out 0.001))
An earlier scaleback
(docs/archive/history/stdlib-arrow-scaleback-plan.md)
removed ArrowZero, ArrowPlus, ArrowChoice, ArrowLoop, and ArrowApply
because no instances backed them (closure-returning instance methods tripped a
codegen bug, and several stubs needed Either/Left/Right sum types). Both
gaps have since closed, so the hierarchy was reintroduced
(docs/archive/history/stdlib-arrow-typeclass-reintroduction-plan.md)
and is now live in stdlib/arrow.tur -- see the Typeclass dispatch section
above for the full table and per-class examples. Category and the honest
Kleisli ArrowZero were added on top
(docs/archive/history/category-arrowzero-implementation-plan.md).
ArrowZero/ArrowPlus remain uninstantiated at (->) by design (no zero for a
total function); Kleisli is where zero-arrow honestly lives.
The examples/signal-processing/ directory contains a three-step tutorial
that you can run directly:
tur run examples/signal-processing/01_basics.tur # Arrow fundamentals
tur run examples/signal-processing/02_signals.tur # Signals and SFs
tur run examples/signal-processing/03_dsp.tur # DSP primitives
01_basics.tur -- covers arr, >>>, arrow laws, and
arrow-first/arrow-second using plain integer functions.
02_signals.tur -- introduces the Signal and SF types, constant,
time-signal, pair-signals, and stateful SFs via state-sf.
03_dsp.tur -- demonstrates oscillators (sine, square, sawtooth,
triangle), processors (gain, offset, invert, abs-sf), mixing
(add, mix, multiply), filters (low-pass, high-pass), and a
complete multi-stage processing chain.
stdlib/arrow.tur -- arr, >>>, compose-float, arrow-first, arrow-second,
par-comp, arrow-split, arrow-const, arrow-dup
stdlib/kleisli.tur -- Kleisli, kleisli, k-apply
stdlib/signal/core.tur -- constant, time-signal, sample, map-signal,
pair-signals, left-signal, right-signal
stdlib/signal/dsp.tur -- sine, square, sawtooth, triangle,
low-pass, high-pass,
gain, mix, add, offset, clip, invert
Category, Arrow, and Kleisli in context with the broader stdlibdefinstance lowers to a C dictionary, the closure-handle convention, and definstance idempotence^arr kind used by the Arrow typeclass