Bidirectional Reactive Programming
Introduction
NOTE: This page is a work in progress.
Reactive systems
Ordinary reactive systems (often called "signals") flows one way: write an input, everything derived updates. Reactive systems assure ~4 properties:
- Minimality — only the affected nodes recompute.
- Consistency — a read is never stale; it always returns the latest value.
- Glitch-freedom — a write that fans out to N sources and reconverges never shows intermediate/inconsistent state; downstream derived values and side-effects fire exactly once.
- Natural acyclicality — by requiring dependencies be defined before use, it is unnatural to create cycles in normal use.
Dependencies are implicit, so one need only read a value to subscribe to it.
Lenses vs two-way binding
// two-way binding stuff, see notes doc // lens stuff and how they might address the problems with two-way binding
Bireactivity
Bi-directional reactivity extends the lazy propagation model of signals to allow backward propagation, allowing us to write to a derived value.
Bireactive systems maintain many of the same properties as forward-only reactivity. Propagation is still acyclic despite the addition of backward edges, so cycles are as hard to create as they were before. Consistency and glitch-freedom are maintained by the same mechanisms as forward-only reactivity.
clamp, quantize, and snap discard information idempotently, so the backward direction projects again.
Unit conversions
Each field is a lens onto one SI-base cell: si.lens(u.fromBase, u.toBase). Units form a vector space under multiplication: times and div add and subtract dimension vectors, pow scales them; two quantities convert when their vectors match.
Geometry
Coordinate spaces through world.lens(fwd, bwd): euclidean, oblique, polar, log-polar, toroidal.
Reflections, rotations, scales, and affine maps have closed-form inverses.
A pulley as two affine(-1, L) lenses.
Meshed gears: turn any one and the rest follow.
Drag a vertex; the edge-midpoints and centroid follow. Each derived point is a mean lens, so every backward step is one average.
The midpoint reads each handle's dragging, so a held endpoint stays pinned while the free end absorbs the difference.
A best-fit line and circle sharing one centroid.
meanSpread(inputs) ⇌ {mean, spread} for any linear type with a metric: vectors, colours, poses.
A pose tree: world = compose(parent.world, local), and a drag inverts through decompose.
A Sankey diagram where conservation (in = out) holds at every node. Four free numbers fix every width; each width is a lens, so dragging one re-solves the rest.
Lenses across types
The two ends of a lens needn't share a type. With a boolean target, forward is a predicate (v > t, box.contains(p), a ≈ b) and backward nudges the source to satisfy it.
(Range, Range) ⇌ AllenRelation reads two intervals as one of Allen's thirteen relations; setting the relation reshapes the second interval.
(Box, Box) ⇌ RCC-8, the same idea in two dimensions.
Collections
A todo tree of Tri values (true | false | "mixed"). A click propagates up to ancestors and down to children.
A Flags value is one integer with named bits; flag(name) is a Bool lens over a single bit.
An array cell arr(items) has writable lens views (filter / sortBy / groupBy).
is(c => c.value.done, false) runs forward (test) and backward (assert). board.move(card, column, i) writes the group field, splices the base order, and asserts the filter.
Text
A Template is a multi-parent lens over typed slot cells (lit₀ slot₀ lit₁ … litₙ), rendering forward and parsing back. Each slot carries a string ⇄ T codec.
Each projection is lossy, so it keeps what it drops in a complement. Edit any pane and the source updates; the others re-derive.
Str has trim / reverse / slice / split; split(/\s+/) returns an Arr of segment lenses, so editing, adding, or reordering a word rewrites the source.
Reg is a bidirectional regex-lens algebra: copy / lit / seq / alt / opt / star, each combinator a lens. Leaves compile to a Brzozowski automaton and the grammar to a tagged Thompson program run as a PikeVM, so an unambiguous grammar parses in linear time without backtracking. Ambiguity is rejected at construction with a witness string that parses two ways (copy(/\d+/).then(copy(/\d+/)) names "00"). A write whose source is off-language is rejected instead of clobbering the rest. bind exposes each named capture as an editable handle (copy → Writable<Str>, star → Arr); the backward pass reprints the source, preserving anything the view never named. reg.optic() exposes a grammar as an Optic<string, V> for compose(...) and cell.through(...). A star's element cells are lenses, so grammars nest: a line-splitter over a cell-splitter makes a grid editable in both dimensions.
spans reports where each capture sits in the source, so the parse can be drawn onto the string. Below, the coloured decomposition is get; the field controls drive put. Break the shape and the lens stops writing.
Compose a grammar's word cells with caseFold for case-preserving find/replace: the grammar locates words, caseFold carries each occurrence's case (UPPER / lower / Title).
Because reg.optic() is an Optic, one string can be edited through several grammars at once. Each pane is source.through(canonical, format(other)) — the same key/value list as a URL query, as key: value lines, and as a compact form.
The playground: pick a grammar a single-pass parser can't handle, watch the parse re-derive as you type, and see ambiguous grammars rejected with the input that would parse two ways.
Editing JSON, YAML, TOML, and EDN through one hub. The parsers are error-tolerant, so a broken pane stops writing the hub but keeps absorbing the other panes' edits.
Schema evolution
A migration is a pipe of small lenses (renameField, addField, nestFields, splitField, mapField). Each step keeps what it drops in a complement, so the whole composition round-trips even where individual steps are lossy.
Large & costly data
To work with large or costly data, the graph propagates handles instead of values. A Canvas carries an RGBA float texture; the graph compares a monotonic epoch on the handle.
A Field<T> is the same idea with a generic T. Field<Vec> runs Gray–Scott reaction–diffusion: field.evolve(kernel) steps the PDE, field.colormap(V) is a Field → Canvas lens, and field.regionMean(box) is a num cell.
Approximate inverses
When the inverse has no closed form, the backward pass runs a solver, still one pass from the outside. An N-link arm is a Vec.lens running inverse kinematics on each write.
Learning
Each layer is a lens over its weight cell. Forward computes act(W·x + b). Backward takes dL/da, writes a gradient step to the weight cell, and returns dL/dx. Composing layers composes the backward passes in reverse, which is reverse-mode autodiff. There is no optimiser object and no training loop; one gradient step is a single backward write.
Finite-difference checks in the test suite confirm the backward write equals the true gradient on every layer.
A 2D classifier. The background is the predicted class probability over the plane, so the decision boundary forms as it trains. Ringed points are held-out test data (green = correct). Drag, add, or flip points and re-train.
The same net, wider, on raw pixels. Draw a shape; the bar is the live P(circle). Training data is a stream of generated shapes, so the label is whatever the generator drew. dream runs the same lens with weights frozen: the cotangent flows past the fixed weights to the input cell, so gradient-ascending the pixels paints the prototype for "circle".
Animation
The animation runtime is built on generators. A generator yields control up; the runtime passes dt back down as the resume value.
The runtime calls .next(dt); generators compose by calling each other for sequencing, parallelism, and time scope.
A generator can pull dt and forward a changed value: slow motion, reverse, pause, jitter.
To wait without a fixed duration, a generator yields (wake) => dispose; the runtime parks it until wake(value).
| Yield | Means |
|---|---|
| yield | wait one frame, resume with dt |
| yield 0.5 | sleep half a second |
| yield gen | spawn a child, wait for it |
| yield [a, b] | spawn N in parallel, wait for all |
| yield (wake) => … | suspend on a callback-shaped source |
| yield detach(g) | spawn at root; outlives the yielding parent |
| yield cut(v) | from inside a group: settle group with v |
Sequencing is yield*, parallelism is yield [a, b, c]. Cancellation is cooperative through .until(stop) or hard via gen.return(); finally runs either way.
cut(v) is Prolog's cut: a child returning it settles its group with v and cancels its siblings. race, firstN, firstMatching, anySuccess, and allSettled are each one closure over it.
Every value signal has .to(target, dur, ease?), a chainable tween that is also an animator.
spring, toward, and attract pull toward a reactive target; wave is closed-form motion and driven is the escape hatch.
when(sig) parks until truthy, untilChange(sig) until the next change. play(p) lifts a number, array, generator, suspend function, or signal into one surface.
A row of cards, each width behind a clamp(MIN_W, ∞) edge so a handle can't drag it below the minimum.
A centroid, mean rotation, and mean scale animated in parallel.
A timeline is a clock with clips over (at, dur) ranges, each exposing a normalized t. yield* tl runs the clock to the total duration.
A claim is a labeled boolean over a predicate, composing with .and / .or / .not / .during / .before because it is itself a cell.
The debugger lays the trace (a gantt of factory invocations) beside α(t) coloured by author, with the claim strips on the same axis. A buggy nudge overshoots α = 1.
.to dispatches on traits: tween, spring, toward, and attract read linear, lerp, and metric from each class's static traits.
A new value type is one class with a trait dict.
polygon.to(target, dur) then works on the same machinery; adding linear and metric brings spring, toward, and attract along.
A centroid is an ordinary cell, so two animations can share one position, the motion their per-frame mean.
tex renders MathML through Temml; part() markers become addressable child shapes with their own transform, opacity, and colour.
marker.register("id") and <md-marker sym="id"> share one marker.active cell, a derived OR over every binding. yield* play(marker.active) parks until any rendering activates it. Three markers tie this text to the diagram:
code is tex's sibling, a reactive source in a text wrapper. c.morphTo(src, dur) diffs lines then tokens, wraps the changed ranges, and interpolates their size.
The (wake) => dispose shape carries to native primitives: untilAnimation(a) wakes on a WAAPI finish, untilInView(el) on intersection, scrollProgress() is a lazy scroll signal. native(el, keyframes, opts) wraps Element.animate as an animator.
The same pipeline drives a <canvas> with a per-frame loop.
A spring over a transform, with phantom poses trailing behind.
Anchor points track a shape as it animates.
A geometric construction on a timeline: axis, ticks, labels, bounding box, centroid.
The runtime's test suite, run in the browser on a fresh Anim driven by step(dt).
Dragging
Inspired by Dragology and its d DSL, re-expressed with reactive lenses.
order.indexOf(tile) is a writable Num lens (read = the index, write = a reorder).
d.between is the continuous sibling of d.closest. A node's three presets are its own corners, so dragging any node steers the morph.
Drag a planet to any orbit around either sun: each orbit is a vary track (project the pointer onto the ring), closest picks across both suns.
The puck's behaviour is three d specs selected by a knob, so the same algebra applies reflexively.
Collaborative documents
An adapter to Automerge CRDT documents makes a doc into writable cells or a deep store. Because lenses compose, multiple UIs over one document chain and stack as views A ▸ B ▸ C, each a set of lenses over the previous:
- canvas — a spatial view (A): drag a shape to write its
x/y. - inspector — one card per shape, bound to
shapeLens(doc, id)(B), with rawx/y/w/h/hue/sat/lumcontrols on top. - spreadsheet — a view of the inspector (C): the same shape lens reprojected through centre, area, aspect ratio, hex.
Editing area scales the box on the canvas; a slider in the inspector moves the centre in the sheet. Edits run both ways, across tabs too. Copy the scene id (under the canvas) into a second tab to collaborate.
Cycles & constraints
Some relationships have no source end: a four-bar linkage or cloth simulation isn't a lens. These run as cyclic regions (strongly connected components) solved iteratively and written back in one batch().
Propagator networks
A propagator declares the cells it reads and writes and narrows the writes via merge (lattice meet). Meet only narrows, so termination follows from the lattice.
Layout combinators.
A 9×9 sudoku with 27 allDifferent relations to narrow to a solution (or a contradiction).
Type inference by the same approach.
Graph layout on the interval lattice: order(layer(u), layer(v), 1) per edge, narrowed to each layer's longest path.
Physics & numerical constraints
Augmented Vertex Block Descent with small constraints (distance, angle, onCircle, generic, …).
physics({ gravity }) bakes a time-stepper into the pipeline, advanced with step(dt).
gap, inside, and gravity as constraints and forces.
A four-bar linkage solved by a vector-loop Newton step each frame, seeded from the last.
Each circle is fixed to (R·sin t, R·sin 2t / 2) by a generic constraint. Near the origin both branches are admissible and the solver can flip.
Three numbers and one generic constraint solve a² + b² = c².
Constraints as loci: onCircle(P, center, r), collinear(P, A, B).
A slider-crank: two distances and a collinear over six cells, four pinned.
Force-directed layout: edge springs plus pairwise gap constraints.
A constraint cluster exposed as a Writable<Vec> via exposeVec; procrustes(tips) lays a move/spin/size frame over three fingertips.
~ Scratchboard ~
Mostly rough demos that aren't very good yet and may not survive the final cut.
Array<Num> ⇌ Array<BinCount> keeps counts and drops positions; transport moves the fewest samples across the nearest boundary.
merge folds many writers into one source. An idempotent meet, a last-writer join, tri-state bus resolution, a sum.
mix(weights, branches) reads as a weighted sum and writes back split by weight. select and crossfade are the same lens with control on the weight simplex.
A lens is itself a value, so it can live in a cell. through(src, frame) tracks the frame forward and inverts whatever it holds backward.
Inverse EQ: drag the response curve and factor solves the band gains, which an effect pushes onto live filters.
A Soulver-style calculator. Mark a leaf as the unknown, then type or drag any result that depends on it; a 1-D Newton solve back-fills the leaf and rewrites it in place.
An optical bench: the beam is one derive that reflects off the nearest surface each step. Mirror midpoints are mean lenses; the ellipse's semi-major axis is a Vec.lens.
A self-similar tree from one rule (branch angle, length ratio). Dragging a node inverts through a multi-output lens that rewrites the rule, so every level updates.
A cubic Bézier read as {start, end, startTangent, endTangent} instead of raw control points.
A confocal family of ellipses: two foci and a derived shape. ellipse(center, a, b, rotation?) takes a reactive value on every parameter.
Kepler orbits. The forward path solves M = E − e·sin E numerically; the backward is closed-form.
A waveform and its spectrum: coeffs.through(iso(synthesize, analyze)), an invertible change of basis.
Each clock hand is an affine view of time, each tip a polar point. Drag a hand to scrub time.
Units as a vector space: times / div / pow over dimension vectors.
Change propagation made visible: a write runs backward to the sources it derives from, then forward over the affected cone.