pin-derive
v0.4.1
Published
Bidirectional constraint networks: pin cells, derive the rest; under-determination as bounded intervals, over-determination as conflicts with origins.
Maintainers
Readme
pin-derive
A spreadsheet that solves in every direction. A real spreadsheet flows one way: inputs, a formula, an output. pin-derive is a small bidirectional constraint engine: define relations between variables, pin any subset, and the rest derive themselves, no matter which cells you picked. Whatever stays undetermined doesn't error — it sits as a range (a "space") that a human or an LLM can choose from; then everything re-derives.
▶ Try it live, the interactive docs site, where you can type into any cell of a spreadsheet that solves in every direction (including the cells that are supposed to be answers).
net.pin("price", point(50)).pin("margin", point(0.2)); // profit derives to 10
// …or grab it by a different handle:
net.pin("profit", point(10)).pin("price", point(50)); // margin derives to 0.2Why
Every team has a haunted spreadsheet.
It started as someone's quick model — pricing, a budget, a boss fight — and over the
years the formulas compounded: a lookup here, a nested IF there, one sheet quietly
referencing another. There's no VBA in it. The formulas alone are Turing-complete
now, and the business runs on top of them. You don't touch it, because the moment
you change one cell you can't predict what reflows.
Here's what nobody says out loud about that spreadsheet: it isn't one model. It's the same model, written over and over, once for every direction anyone ever needed to ask it a question from.
A boss fight has three numbers that relate: HP, damage per second, and how long the
fight lasts. One fact: this fight takes about thirty seconds. The spreadsheet
can't hold that fact. It can only compute forward, so it grows
secondsFromHPAndDamage. Then someone wants to tune by feel, and it grows
hpFromSecondsAndDamage. Then someone needs the weapon math, and it grows
damageFromSecondsAndHP, the same relationship, hand-derived backwards three times,
kept in sync by hand forever. One was patched last month. One wasn't. They disagree
by about fifteen percent. The boss shipped anyway, players have been fighting it for
two years, and the "correct" length depends on which function you ask, which nobody
does, because it works. Mostly.
That's the sickness, and it comes from one assumption so baked in nobody questions it: a variable holds a value. Pin a cell and it's a number. Leave it alone and it's a hole: either empty, or anything, with nothing in between. The only way to ask "what would the inputs have to be?" in a world like that is to write a new function that re-derives them. So you do. And then you do it again. And eventually the model is a graveyard of frozen directions, each one slightly out of agreement with the others, none of them aware they're the same fact.
pin-derive starts from the opposite assumption: a cell holds partial information. A box can be full, empty, or half-full, narrowing as you learn more. The relationship is written once. The engine inverts it for you. Three things fall out that none of those frozen functions can do:
- Any direction, one model. Pin the HP and the damage, and the fight length
derives. Pin the fight length instead, and the HP derives. No
hpFromSecondsAndDamage. The explosion doesn't get cleaned up — it never happens. - The unknown is a range, not a crash. Pin too little and a cell comes back as the space of what's still possible — somewhere between 40 and 60 — for you, or a balancing loop, or an LLM, to choose from. Under-determination isn't an error. It's the answer being honest about what it doesn't yet know.
- The impossible points. Push a value out of range and the one cell that broke
reads
⊥, and the engine names which fact collided with which. Everything else keeps its value. No stack trace. Just the cell that lied.
It's for systems where someone — a human, a balancing loop, an LLM — is in the loop, and needs to see the possible range, pick inside it, and watch the rest re-derive. Guided generation. Config that stays consistent when you flip a knob. A model grounded by real limits instead of hallucinating past them.
Not a spreadsheet, not an industrial solver. A thin core, about two hundred lines, that you teach the relationships of your domain once, and it answers from any end you ask. The engine is small. The value is in the relations, written once.
What it does
- Solves in any direction: pin price + margin and profit derives, or pin profit + price and margin derives instead. Same network, same relations, no separate "solve for X" path.
- Under-determination becomes a range, not an error: pin too little and a cell returns the possible range (the decision surface) for a human or an LLM to choose from.
- Conflicts localize instead of crashing: over-constrain the network and the
one cell that broke reads
⊥, withconflictOrigins()naming where two facts first collided andconflictOriginDetails()reporting the relaxation gap; everything else keeps its derived value. - A growing relation library: equality, sum, product, affine, quantize,
ordering (
lte/within), n-ary min/max, clamp, and invertible lookup/curve relations for non-linear progression, all multidirectional (see Reference). - Serializable as JSON: a network is also a plain spec (cells, relations, pin/commit replay) that's the cross-language contract between the TS, Rust, and Python runtimes.
- Two fill policies for ranges:
solveLPpicks an optimum under an objective;fillProportionalpicks fair interior allocations, with optional quantum-grid reconciliation. Both commit through the same API as a human or LLM choice. - Three runtimes, one conformance suite: the TypeScript engine is the reference; a Rust port and its wasm build (bound from both JS and Python) must reproduce the same fixtures exactly.
Performance
The core is built for interactive calculators, not offline theorem proving. The scheduler is worklist-based: once a cell changes, propagation re-fires only the relations adjacent to changed cells, so sparse chains and DAG-shaped networks scale roughly linearly.
Benchmarks live in bench/ and can be run with:
pnpm benchThe harness expands 40 workloads across four runtimes and validates that every runtime returns the same snapshot. Times below are TypeScript medians from the current local run.
Benchmark results
| workload | size | median solve time | |---|---:|---:| | equal chain, forward | 256 | 70.8µs | | equal chain, backward | 256 | 88.0µs | | affine chain, forward | 256 | 201µs | | affine chain, backward | 256 | 291µs | | random DAG | 512 | 347µs | | wide sum | 128 parts | 1.06ms |
Scaling snapshot
| workload | small | large | growth shape in the harness | |---|---:|---:|---| | equal chain, backward | 1.21µs @ n=4 | 88.0µs @ n=256 | ~linear after startup | | affine chain, backward | 4.04µs @ n=4 | 291µs @ n=256 | ~linear, heavier relation | | random DAG | 11.8µs @ n=16 | 347µs @ n=512 | ~linear sparse propagation | | wide sum | 5.00µs @ n=4 | 1.06ms @ n=128 | high-arity ordered-replay stress case |
The practical read: sparse/local networks are comfortably sub-millisecond at
hundreds of cells, including backward solves. Wide, high-arity relations with many
pinned inputs are the stress case; wide-sum remains usable at 128 parts, but its
ordered replay cost grows faster than sparse propagation. Future performance work,
including stateful/incremental APIs for hot loops, is tracked in the
design roadmap.
The one idea
A cell holds partial information, not a value. Its content is an interval, and its status is read off that content. You never declare a variable to be an input or an output:
| cell content | status | role |
|---------------------|---------------|-----------------------------------|
| (-∞, ∞) | free | an open input |
| a bounded interval | bounded | a decision — a space to fill |
| a single point | derived | a locked, computed value |
| a point you set | pinned | a held assumption |
| empty ⊥ | conflicting | the cell that broke |
A relation is multidirectional: it has no inputs or outputs, it refines every cell it touches from every other. So the same network solves in any direction depending on what's pinned. An underdetermined answer becomes a bounded space (the decision surface) rather than an error.
Pin a fact the engine can't satisfy and the contradiction localizes instead of
crashing: drag a bound out of range and watch it surface as ⊥, with the rest of
the network still showing what it can:
For something interactive, the docs site hosts game-dev-flavored explorers you can drive in every direction: ▶ try them live.
| explorer | what it demonstrates |
|---|---|
| Ballistics | A cannon's Aim (speed, angle) and DPS (fire rate, falloff) tabs share one range cell: pin a target DPS and the range backs out the constraint, which then narrows what aim can reach it. |
| Orbit | Kepler's relations (mass, radius, speed, period) solved bidirectionally: pin mass + radius and the orbit derives; pin a target period instead and mass backs out; over-constrain it and the conflict surfaces on the exact cell that broke, with the feasible range still shown. |
Getting started
TypeScript
pnpm add pin-derive # or: npm install pin-deriveRequires Node 18+. The package ships ESM with type declarations. The bare import gives you the full public API:
import { Network, product, quantize, fromSpec, solveLP, fillProportional } from "pin-derive";Or import from a focused subpath when you want only one slice: pin-derive/engine,
pin-derive/relations, pin-derive/spec, pin-derive/lp (the LP solver:
resolve a feasible envelope to one optimal point under an objective), or
pin-derive/fill (the proportional allocation chooser). The subpaths are the
canonical entries; the root re-exports all of them for convenience.
import { Network, point, NONNEG } from "pin-derive/engine";
import { product } from "pin-derive/relations";
const net = new Network();
net.cell("price", NONNEG, "price ($)");
net.cell("margin", NONNEG, "margin");
net.cell("profit", NONNEG, "profit ($)");
net.relate(product("profit", "price", "margin")); // profit = price × margin
// Solve forward: pin price + margin, profit derives.
net.pin("price", point(50)).pin("margin", point(0.2));
console.log(net.report()); price ($) pinned 50
margin pinned 0.2
profit ($) derived 10The same network runs backwards: pin the result, recover an input:
net.pin("profit", point(10)).pin("price", point(50));
// → margin derived 0.2Save either snippet to a file and run it with npx tsx <file>. Pin fewer cells
than the relation needs and the under-determined ones come back as a range to
choose from instead of an error. See
Reference → When the answer is a space.
Rust
The TypeScript engine is the reference implementation; the Rust port is conformance-tested against the same conformance fixtures. If the two disagree, the Rust port is wrong, not the fixture. Not yet published to crates.io; build and test it from source:
cd rust
cargo test --workspace # lattice unit tests + every conformance fixtureIt also compiles to a small wasm core (pin_derive_wasm, a 4-function C ABI:
spec JSON in, snapshot JSON out) that the JS and Python packages bind to. See the
Rust package docs for the wasm build and the ABI.
JavaScript wasm
The JS wasm package lives in rust/js. It bundles the same wasm core
as package data and exports host-agnostic ESM: browser and Node callers both supply
the wasm bytes, then call the same solve() API.
cd rust/js
pnpm run test # generates declarations and replays every fixture through wasmimport { readFile } from "node:fs/promises";
import { load, wasmUrl } from "pin-derive-wasm";
const engine = await load(await readFile(wasmUrl));
const snapshot = engine.solve({ /* the same JSON spec shown in the API reference */ });Python
The Python package lives in rust/python. It bundles the same wasm
core as package data and loads it through wasmtime,
so installing the wheel does not require a Rust toolchain:
cd rust/python
uv run --with pytest pytest # runs every fixture through the packaged wasm bindingfrom pin_derive import PinDerive
engine = PinDerive() # loads the bundled wasm
snapshot = engine.solve({...}) # the same JSON spec shown in the API referenceReference
The relation library, the full Network API, the JSON spec format, conflict
handling, and both fill policies (solveLP and fillProportional) are documented
in the API reference. Adding a relation is the most common
extension point. See the extending guide.
Contributing
git clone https://github.com/nightwork-dev/pin-derive && cd pin-derive
pnpm install
pnpm test # vitest — core, relations, spec replay
pnpm typecheck # tsc --noEmitChanges to the engine or spec must stay in conformance with the Rust/wasm port (see Getting started → Rust and the Rust package docs); CI runs the full cross-language parity gate on every PR.
The interactive docs site lives in the site package. Run it locally
with cd site && pnpm install && pnpm --filter web dev.
Full dev setup, the scripts reference, and the cross-language parity workflow are in the contributing guide.
Project layout
src/ engine.ts (core), relations.ts (the library), spec.ts (JSON spec), lp.ts/fill.ts (choosers)
fixtures/ cross-language conformance fixtures (spec in → snapshot out)
rust/ Rust port + wasm build (core/wasm/), plus JS and Python wasm packages
(js/python/) — all tested against the same fixtures
site/ interactive docs site (TanStack Start SPA) — landing, concepts, and playgrounds
docs/ DESIGN.md (intent/roadmap), REFERENCE.md (API), EXTENDING.md (add a relation)Docs
- Design notes: the reasoning behind the engine: why partial information, why multidirectional relations, the two domains it serves, the known seams, and the unbuilt semantic layer on the roadmap
- API reference: the relation library, the
NetworkAPI, the JSON spec, conflicts, and the LP/proportional-fill choosers - Extending guide: how to add a relation
- Specs: dated specs for individual features
- Contributing guide: dev setup, cross-language parity, the docs site
