Ither is a toolkit for combinatorial mapping of structured and text-based data. A formula defines a family of structures and a reversible mapping that gives each structure an exact numerical coordinate. The compiled space encodes and decodes one structure at a time; queries explore many coordinates as grids, rays or lists; analysis inspects spaces and checks live or saved datasets.
With integer axes, a design space can be enumerated, searched, sampled, paged through and stored by position — without generating the whole family first.
Status: Ither is a young project. 0.3 is the first public release. The API may still change before 1.0, and its manual and API reference are still being written.
Drag a block by its title, close it with ×; auto layout puts everything back.
Each one is a space of its own. Move through it, then open its formula in the workbench.
Ither's three tiers, on one space:
SpacePick a formula — the site's spaces, or the library's formula bank — edit it and compile. The query runs by itself whenever it can; the records, the request and the report show what the library answered, and the diagnostics read it.
Ither in brief: the README, block by block. The rest lives with the library:
Status: version 0.3.0, the first public release — very young. The formula, query and analysis tiers each have their contract; the API may still change before 1.0. The manual and the API reference are still being written. MPL 2.0.
npm install ither
import { Ither } from 'ither';
ES modules, no runtime dependencies, no consumer build step; Node ≥ 20.10, modern browsers and Deno. The same API works in server processes, command-line scripts and browser apps. Ither does not start a server, read arguments or choose storage; those stay with the application. Without a package manager, vendor dist/ither.min.js.
A space is defined by one object — a formula.
import { Ither } from 'ither';
const space = Ither.from({
layers: { f: { type: 'AST', domains: {
x: ['a', 'b', 'c', '0', '1'], o2: ['add', 'mul', 'sub'] } } },
});
space.encode({ f: 'add(mul(a,b),c)' }); // [1337n]
space.decode([1337n]); // { f: 'add(mul(a,b),c)' }
space.decode([1338n]); // a neighbouring expression
A coordinate is an array with one BigInt per axis; a structure is an object keyed by layer name — always, also for one axis or one layer.
A design space normally has no addresses: you edit structures, but cannot ask what lives at a particular point. Ither gives it integer axes. That makes it possible to enumerate, search, sample, page through and store positions in the space without generating the whole family first.
For a bounded contiguous axis, coordinate + 1 means the next structure in the ordering chosen by the formula. With several axes, a coordinate is an array of those integers. Projection and combine determine whether adjacency preserves a useful notion of locality, deliberately destroys it, or uses a sparse range that can contain holes.
| tier | input | does | output |
|---|---|---|---|
| 1. formula | a formula object | defines the coordinate ↔ structure mapping, strictly | a Space |
| 2. query | a Space and coordinates (or structures) | decodes (or encodes) them, one record contract for every input | records, progress and final reports, and on request a manifest |
| 3. analysis | a space, a geometry, records (live or saved), a manifest | runs capsules, each on the inputs it declares; one that cannot run says so | an analysis report with provenance |
formula ──▶ Space ──query──▶ records + manifest ──analysis──▶ report
Each tier is useful alone: a Space answers single encode / decode calls without a query, and analysis can read records saved months ago — with the manifest saying which formula and version produced them. The workbench is all three, on one space.
{ layers, projection?, options? }Ither.from(formula); you encode, decode and query itBigInt per projection axis; a unique address in the spaceConvention: anything that is an array is ordered and the order is meaningful; anything that is an object is named and its key order is irrelevant.
| type | encodes |
|---|---|
AST | expression trees (topology + symbols jointly) |
OBJ | key/value objects |
ARR | fixed-shape arrays |
STR | fill-in templates |
LSM | L-system grammar spaces |
TRI | convex-polygon triangulations (Catalan) |
NUM | real-valued axes (quantized) |
PERM | permutations of [0,n) (Lehmer) |
Every layer spec uses the same envelope — { type, domains?, shape?, from?, options? }. Anything a formula does not declare correctly is refused, naming the key and the layer.
Name the layers and project them onto axes. A projection entry is one axis; a reference is a layer ('topo') or a part ('fill.o1'). Several references on one axis combine — by the formula's one options.combine: box (the default), gray, morton, hilbert, scramble or cantor — and from chains one layer's output into another's template.
const space = Ither.from({
layers: {
topo: { type: 'AST', domains: { x: ['x'], o1: ['o1'], o2: ['o2'], o3: ['o3'] } },
fill: { type: 'STR', from: 'topo', // template = topo's output
domains: { x: ['F','I','1.0'], o1: ['nrm','sin'], o2: ['dot','add'], o3: ['mix'] } },
},
projection: [ ['topo'], ['fill.x'], ['fill.o1','fill.o2','fill.o3'] ],
});
space.decode([7n, 4n, 1n]);
// { topo: 'o2(x,o1(x))', fill: 'dot(I,sin(I))' }
space.recover({ fill: 'dot(I,sin(I))' });
// the topology read back from the text
const { query, manifest } = await Ither({
space,
query: {
decode: { type: 'grid', axes: [{ start: 0n, end: 9n }, { start: 0n, end: 19n }] },
write(record) {
// { ordinal, coordinate, status: 'structure' | 'hole' | 'error', structure | error }
},
progress(report) { /* fraction, records, outside, remainingMs, … */ },
manifest: true,
},
});
Inputs: a grid, a ray (start, direction, count), a list of coordinates, or encode a list of structures. A point past its axis's range is never answered with another structure: it is omitted, counted in outside, and listed in the manifest. offset and limit page in ordinals; write returning false or an AbortSignal stops cleanly; nextOffset resumes exactly.
const ray = { type: 'ray', start: [0n, 0n], direction: [1n, 1n], count: 100n };
const dryRun = await Ither({ space, analysis: { suite: ['geometry-summary'], geometry: ray } });
const live = await Ither({ space, query: { decode: ray }, analysis: { suite: ['query-suite'] } });
const saved = await Ither({ space, analysis: { records, manifest, suite: ['query-suite', 'lib-suite'] } });
saved.analysis.provenance.mapping; // 'manifest-matches' — or 'manifest-differs'
Each capsule declares the inputs it needs (space, geometry, records, manifest). One that cannot run on what a request supplies gives an inapplicable result naming why, and the rest run. Ither owns no storage format; an analysis.adapt(row, position, context) converts stored rows back into records.
A versioned set of example formulas, shared by the library's tests and client projects — the workbench's formula list includes them.
import { listFormulas, loadFormula } from 'ither/formulas/bank.mjs';
const space = Ither.from(await loadFormula('ast-shader-expression'));
For a given formula, the coordinate ↔ structure mapping is deterministic and identical on any engine or OS — a pure function of (version, canonical formula, structure). Golden coordinates pin it in the tests.
The version travels with what it produces — space.pack() and every query manifest record it — so data from another version is detectable, and analysis reports it instead of refusing it.