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. (A free defn and a typeclass method of
the same name share the value namespace; see
docs/archive/history/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 --
operator mangling 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 (cell-based feedback -- see below) |
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/archive/history/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 -- feedbackarrow-loop feeds part of an arrow's output back as input: given an arrow
a (b, d) (c, d) it produces a b c, wiring the d output round to the d
input. In Haskell that knot is tied by laziness. Turmeric is strict, so the
d slot cannot simply be the value the same run is about to produce -- and
the fix is to hand the looped arrow a LoopCell in slot 1 instead of a
bare value. The cell splits "the value is written" from "the value is read",
which is all laziness was buying:
LoopCell = { filled : int64, value : int64 } ; two words on the heap
Three combinators fill the cell at three different times. They share one
protocol -- slot 1 is always a LoopCell -- so a single looped arrow works
under all three.
| Combinator | The cell starts | Good for | Total? |
|---|---|---|---|
arrow-loop (and its bare twin arrow-loop-lazy) |
empty; filled with the d output when the run returns |
knot-tying: the arrow parks the cell in its c output and something forces it later |
no -- forcing mid-run is the <<loop>> black hole |
arrow-loop-fix |
seeded with d0, refilled once per pass until d stops moving or fuel runs out |
an arrow that reads d strictly, where the answer is a fixpoint |
yes -- fuel bounds it |
arrow-loop-delay |
seeded with d0, carried forward across calls |
a unit delay in the feedback path: call n sees call n-1's d |
yes |
Reading the cell:
| Function | Meaning |
|---|---|
(loop-cell-of slot) |
the erased slot-1 value, typed as a LoopCell |
(loop-cell-ready? c) |
true when the value has been written; false inside an arrow-loop run |
(loop-cell-force c) |
the fed-back value, or a <<loop>> panic when it is not there yet |
The looped arrow's c output is defined by the d output of the same run --
it parks the cell handle in c and the consumer forces it afterwards:
(load "stdlib/arrow.tur")
;; c = the feedback cell itself; d = b * 10, known only at the end of the run.
(defn park-cell [p] : int
```c typedef struct { int64_t e1; int64_t e2; } P;
P *s = (P *)(intptr_t)p;
P *r = (P *)malloc(sizeof(P));
r->e1 = s->e2;
r->e2 = s->e1 * 10;
return (int64_t)(intptr_t)r; ```)
(defn main [] : int
(let [lp (arrow-loop park-cell)
c (loop-cell-of (lp 7))]
(println (loop-cell-force c))) ; => 70
0)
An arrow that forces the cell during the run has demanded its own output;
that is a genuine error, and it panics with <<loop>> rather than quietly
reading a sentinel. Guard with loop-cell-ready? when a miss is expected.
;; newton: d' = (d + b/d)/2, c = d -- iterate to floor(sqrt(b)).
((arrow-loop-fix newton 1 32) 144) ; => 12
fuel is a bound, not an assertion: a non-converging arrow spends its passes
and returns the last c rather than hanging.
;; sum-step: d' = c = d + b -- a running total, seeded at 0.
(let [acc (arrow-loop-delay sum-step 0)]
(acc 1) (acc 2) (acc 3)) ; => 1, 3, 6
The returned arrow is stateful: it owns one cell, so each call advances the
same feedback line. Call arrow-loop-delay again for an independent one.
The arrow layer runs on an erased two-slot heap pair of int64, so a looped
arrow written in Turmeric takes and returns that carrier and ascribes at the
edges:
(load "stdlib/tuple.tur")
(defn prev-step [p : int] : int ; unit delay: emit the previous b
(let [t (:: p (Tuple2 int int))
b (tuple2-1st t)
d (loop-cell-force (loop-cell-of (tuple2-2nd t)))]
(:: (tuple2 d b) :int)))
An inline-C arrow redeclares the cell layout locally, the same way the pair
helpers in stdlib/arrow.tur redeclare P:
typedef struct { int64_t filled; int64_t value; } LC;
Declaring the arrow directly over (Tuple2 int int) works too, and computes
the same values -- the compiler bridges the by-value aggregate to the carrier
at the fat-box boundary:
(defn prev-step [t : (Tuple2 int int)] : (Tuple2 int int)
(tuple2 (loop-cell-force (loop-cell-of (tuple2-2nd t)))
(tuple2-1st t)))
(That crossing used to return garbage and then segfault; see
docs/archive/arrow-struct-typed-arrow-abi.md. tests/fixtures/arrow-struct-typed-arrow
now asserts the two spellings agree under every member of the family.)
Fixtures: arrow-loop-lazy-feedback, arrow-loop-fix, arrow-loop-delay,
arrow-struct-typed-arrow, and arrow-instance-loop-nonrecursive (the
degenerate case, where the arrow ignores d entirely).
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 implementation is the
tur-signalspice in../turmeric-spices/spices/signal/(seedocs/archive/history/tur-signal-rebuild-plan.mdfor the rebuild plan and acceptance state). The code samples below are written againststdlib/signal/...paths; there is nostdlib/signal/in this repo, so 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.
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))
Runnable signal-processing examples ship with the tur-signal spice under
../turmeric-spices/spices/signal/examples/ -- short, per-module programs
covering signal construction, oscillators, filters and shapers, envelopes,
and a simple voice. Clone the turmeric-spices repository next to this one
to run them.
stdlib/arrow.tur -- arr, >>>, arrow-first, arrow-second,
par-comp, arrow-split, arrow-const, arrow-dup
stdlib/kleisli.tur -- Kleisli, kleisli, k-apply
signal/core (spice) -- constant, time-signal, sample, map-signal,
pair-signals
signal/osc (spice) -- sine, square, sawtooth, triangle
signal/filter (spice) -- low-pass, high-pass
signal/shaper (spice) -- 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