pin-derive
v0.11.4
Published
Bidirectional constraint engine over numbers, choices, and subsets — Rust core, wasm handle ABI, host-agnostic ESM binding
Maintainers
Readme
pin-derive
A model that answers from any end you ask it. A cell holds partial information about one of three things: a number (an interval), a choice (which one member of a declared universe), or a subset (which members). A relation narrows every cell it touches from every other, in every direction — there are no inputs and no outputs. Pin whichever facts you happen to know and the rest narrow themselves. There are exactly four outcomes and they are never blurred: a value is still possible, the constraints contradict each other, every value is a checked point, or a work limit stopped the run. Explanations are structural, not searched: every bound carries the set of moves that produced it, so "which pins conflict" and "why is this option gone" are reads.
net.pin(price, point(50)).pin(margin, point(0.2)); // profit derives to 10
net.pin(profit, point(10)).pin(price, point(50)); // margin derives to 0.2 — same relationWhy
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.
That 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.
pin-derive starts from the opposite assumption: a cell holds partial information. The relationship is written once and the engine runs it in every direction. Three things fall out that no frozen function 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 space, not a crash. Pin too little and a cell comes back as what's still possible — somewhere between 40 and 60, one of these two samplers, three to five of these fifty items — for a person, a balancing loop, or an LLM to choose inside. Under-determination is the answer being honest about what it does not yet know.
- The impossible points at itself. Push a value out of range and the one
cell that broke reads
⊥and names the exact moves that broke it, from the justification it was already carrying. Everything else keeps its value.
The second thing it does that a spreadsheet cannot: the categorical half lives in the network too. "This model permits these three samplers." "This model takes 4 to 12 steps." Pin the steps to 25 and the model is eliminated — by the same mechanism, with the same explanation, as a number going out of range. A choice and a number constrain each other, and so does a subset: "three to five distinct items from this catalog, under this budget, none of these" is one cell and one relation, not a validator.
It's for systems where someone — a human, a balancing loop, an LLM — is in the loop and needs to see what is still possible, pick inside it, and watch the rest re-derive. Guided generation. Configuration 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 small lattice core in Rust, with a relation catalog and a chooser roster around it, that you teach the relationships of your domain once.
What it does
Three kinds of value, one lattice. Every cell is an Interval (a number,
±∞ allowed), a Finite (one member of a universe; the value is the set of
members still possible), or a Set (a subset; the value is must and may).
Each has meet, top, bottom, and point. Status is read off the value — free,
bounded, point, conflicting — never declared; provenance is the orthogonal
axis, pinned, chosen, derived.
Relations narrow in every direction. The catalog is the single source: one
entry per relation, generating the constructors in all three languages, the
document schema, the sentences describe() and why() render, and the
reference table. cargo run -p pin-derive-core --bin catalog -- reference
prints it.
| kind | relations |
|---|---|
| numeric | equal, sum, linear, product, affine, quantize, lt, lte, within, min, max, clamp, lookup, curve, pow |
| choice | table (the one categorical relation), rank; and lte/min/max over an ordered universe |
| subset | select (count-aware weighted selection), count, sum_over, member_of, subset_of |
| helpers, which lower to one table | permits, ranges, compat, all, any |
| moves | restrict, exclude (choice); has_all, has_none, exactly (subset) |
The helpers exist because an author says "the model permits these samplers," not "here is a table." They are authored by name, lowered by the engine, and exported back in the form they were written in.
Parameters that depend on a choice. A relation's parameter is a constant, a
cell, by(choiceCell, map) — one constant per member, fired per still-possible
member, eliminating members whose result cannot meet the target — or
bands([[upTo, value], …]), a constant that depends on the magnitude of the
value being related. quantize(width, raw, by(model, { krea2: 16, flux: 64 }))
is one relation, not an enumeration of every multiple.
Explanations are structural. Every bound carries the bitset of moves whose
narrowings produced it. A meet that reaches ⊥ records the union of the two
justifications, so conflicts() is a read, not a search. why(cell) gives the
narrowing records with a generated sentence; eliminated(cell) gives each
removed member with the relation and the moves that removed it. The recorded set
is sufficient, not minimal.
Live networks. Compile once; sessions are cheap to create, clone and fork.
Adding cells and relations extends a solved session in place, because they can
only narrow. Anything that could widen — removing a relation, widening a domain,
adding a table row — makes a new revision and replays the ledger. Retraction is
never in place: unpin and preview replay inside the engine.
Identity and compare-and-swap. solveFingerprint() identifies the input
state (structure plus ledger); snapshotFingerprint() identifies the solved
state; decisionDelta diffs two. A batch commits under an acceptance predicate
with the base fingerprint as expect, so a proposal built against a state
somebody has since committed into is refused as a stale base rather than landing
on a state it never saw.
Choosers finish the job. Rounding (quantized cells under a pinned sum, by
largest remainder), NearestDefault, Lp, and Fill ship in the box behind one
Chooser trait, and no chooser invents a point on an infeasible network — it
returns a structured refusal. pin-derive contains no randomness: stochastic
choice reaches it through the same trait, from outside.
Performance
The core is built for interactive loops, not offline theorem proving. The scheduler is a worklist: a refinement that changes a cell queues the relations adjacent to it, each at most once until it fires, and a fire that changes nothing queues nothing. Ids resolve to indices at build time and the solve loop allocates nothing.
The numbers below are one release-build run of
rust/core/examples/measurements.rs on one
developer machine, reported as a guide rather than a gate. Reproduce them with
cd rust && cargo run --release -p pin-derive-core --example measurements.
| workload | n | fires | refines | wall | |---|---:|---:|---:|---| | equal chain, build + solve | 300 | 598 | 1,197 | 0.175 ms solve / 0.326 ms build | | equal chain, build + solve | 1,000 | 1,998 | 3,997 | 0.574 ms solve / 0.419 ms build | | equal chain, build + solve | 3,000 | 5,998 | 11,997 | 1.644 ms solve / 1.110 ms build | | equal chain, build + solve | 10,000 | 19,998 | 39,997 | 5.128 ms solve / 3.640 ms build | | build + solve + drop networks of 20–50 cells | 10,000 | — | — | 22.7 µs each | | solves of a 10-cell network (fork + pin + solve) | 10,000 | — | — | 3.1 µs each | | 1,000-cell network with one 2,500-row table | 1,000 | 1,999 | 3,999 | 0.492 ms solve / 0.299 ms build | | document import, 1,000-cell chain | 1,000 | — | — | 1.578 ms | | extend a solved 1,000-cell session by one cell | 1 | 2 | 4 | 11.2 µs | | …then 100 more cells and relations, same session | 100 | — | — | 65.6 µs | | build-and-free cycles, 20 cells each | 10,000 | — | — | 12.9 µs each, RSS +32 KB |
The shape is the claim: 2.00 fires per relation from 300 cells to 10,000, so a chain stays linear, and extension appends the delta instead of recompiling the structure.
Through the JavaScript binding the same workloads pay the wasm boundary once per
builder call and nothing per solve. One run of pnpm bench
(js/bench.mjs), same machine:
| workload | n | wall | |---|---:|---| | 1,000-cell equal chain, cells / relations / first pin + solve | 1,000 | 0.468 ms / 2.287 ms / 0.604 ms | | 10,000-cell equal chain, cells / relations / first pin + solve | 10,000 | 3.598 ms / 20.050 ms / 4.894 ms | | 4-cell network, build + solve + dispose | 5,000 | 10.5 µs each | | 200-cell chain, one extra pin, re-solve, unpin | 200 | 126 µs each |
A relation crosses the boundary as JSON text, so declaring one costs about 2.3 µs through the binding against 1.2 µs for the same descriptor through the native document path; solving does not cross at all. On the same workloads the 0.10.2 TypeScript engine took 23 ms for the 1,000-cell chain, did not converge the 10,000-cell chain within its fire cap, and re-solved the 200-cell chain in 0.85 ms per pin.
Those are chains. On consumer-shaped networks the cost is measured separately in
docs/specs/evidence/2026-09-05-scaling.md,
which grows five shapes taken from the consumer tests until they stop. On 0.11.1
a 120,008-cell fan-out economy — eight global knobs over 10,000 entities — builds
and solves in 93 ms and 107 MB natively, and the same network through the wasm
binding solves in 1.1 s at a 278 MB peak of linear memory. A single sum over
100,000 bounded parts solves in 21 ms. The evidence file also names where each
shape stops and why.
Getting started
The same program, one init line apart, in each language.
JavaScript
npm install pin-derive # or: pnpm add pin-deriveNode 20+. The package is ESM with type declarations, and ships the wasm binary
it loads. In Node, init() finds the bundled asset; in a browser, pass the
bytes you fetched (wasmUrl is exported for that).
import { Network, init, point, product } from "pin-derive";
await init();
const net = new Network();
const price = net.cell("price", { lo: 0 });
const margin = net.cell("margin", { lo: 0 });
const profit = net.cell("profit", { lo: 0 });
net.relate(product(profit, price, margin));
// forward: pin price and margin, profit derives
net.pin(price, point(50)).pin(margin, point(0.2));
console.log(net.solve(), net.value(profit), net.status(profit));
// → converged { lo: 10, hi: 10 } pointThe same network runs backwards — pin profit and price instead, and margin
derives to { lo: 0.2, hi: 0.2 }. A Network owns a wasm handle: use
using net = new Network(), or call net.dispose().
Rust
cargo add pin-derive-coreuse pin_derive_core::network::{point, Network};
use pin_derive_core::relations::numeric::basic::product;
use pin_derive_core::session::SolveOutcome;
let mut net = Network::new();
let price = net.cell("price", None).unwrap();
let margin = net.cell("margin", None).unwrap();
let profit = net.cell("profit", None).unwrap();
net.relate(Box::new(product(profit, price, margin))).unwrap();
net.pin(price, point(50.0)).pin(margin, point(0.2));
assert_eq!(net.solve(), SolveOutcome::Converged);
println!("profit = {:?}", net.value(profit).as_interval().unwrap());
// → profit = Interval { lo: 10.0, hi: 10.0 }The crate is the engine; the wasm module and both other bindings are built from it. See the Rust workspace README.
Python
uv add pin-derive # or: pip install pin-deriveThe wheel bundles the same wasm binary and loads it through wasmtime, so
installing it does not require a Rust toolchain.
from pin_derive import Network, point, product
with Network() as net:
price = net.cell("price", {"lo": 0})
margin = net.cell("margin", {"lo": 0})
profit = net.cell("profit", {"lo": 0})
net.relate(product(profit, price, margin))
net.pin(price, point(50)).pin(margin, point(0.2))
print(net.solve(), net.value(profit), net.status(profit))
# → converged {'lo': 10.0, 'hi': 10.0} pointSee the package README.
Documents
A network is also a plain JSON document — cells, relations, and the pin/commit
ledger — and import calls the same builder in document order, so there is no
second construction path. Helpers appear by name and export writes back
whichever form was authored. meta is opaque provenance the caller owns; it
round-trips through decisions(), surface(), and export, so
extract-build-solve-write-back is one loop.
{
"cells": [
{ "id": "steps", "init": { "lo": 1, "hi": 100 } },
{ "id": "model", "kind": "finite", "universe": ["krea2", "flux"] }
],
"relations": [
{ "type": "ranges", "a": "model",
"map": { "krea2": { "steps": [4, 12] }, "flux": { "steps": [20, 30] } } }
],
"pins": [{ "cell": "steps", "value": { "lo": 20, "hi": 30 } }]
}kind defaults to "interval", so every 0.x document is a valid 0.11
document and the 0.x fixture corpus imports as-is. YAML is a documented stub.
The document is what fingerprints are taken over.
Stability
Pre-1.0, so a behaviour change is a minor bump.
0.11 is a rewrite, not an upgrade. The engine, the builder API, the outcomes,
and the explanation surface all changed. The 0.10 line is preserved by its tags
(v0.10.0 … v0.10.2) and by the packages already published; nothing on those
tags moves, and a ^0.10 range does not pull 0.11 in. The
release notes carry the mapping from every 0.10 entry
point to its 0.11 replacement.
The npm package pin-derive is now the wasm binding; pin-derive-wasm on npm is
deprecated in favour of it. The crate pin-derive-core and the PyPI package
pin-derive are both a new API. The current release on all three registries is
0.11.1.
Project layout
rust/core crate pin-derive-core — the engine: values, catalog, relations,
sessions, choosers, documents, reads
rust/wasm the wasm module (handle ABI, ABI 4), built from the core
rust/python PyPI pin-derive — the Python binding, bundling the wasm
js/ npm pin-derive — the JS binding: pin-derive.mjs, its .d.mts,
the wasm asset, and the smoke suite
fixtures/ the 0.x document corpus, kept as a compatibility check
scripts/ wasm-build.mjs (the only wasm build) and the size gate
site/ the interactive docs site
docs/ REFERENCE.md (normative), DESIGN.md, EXTENDING.md,
RELEASE-NOTES.md, specs/ (dated), archive/ (superseded)Docs
- API reference: the normative contract — the relation catalog, the builder, sessions and revisions, the four outcomes, justifications, identity, choosers, documents, and the wasm ABI
- Design notes: what the engine is and where the design record lives
- Extending guide: how to add a relation or a helper through the catalog
- Release notes: what changed and what to change
- Specs: dated specs, including the pin-derive 2 specification
- Contributing guide: dev setup and the gates
The interactive docs site still documents 0.10 and is pinned to the published 0.10 packages. It is being ported to 0.11 separately from this repository's release line.
