gdp-wired 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,12 +2,9 @@
2
2
 
3
3
  # gdp-wired
4
4
 
5
- Laws about stateful rules (budgets, caps, rate limits, access policies), proven in TypeScript by a small trusted
6
- kernel, with an untrusted prover that writes the proofs, an optional Lean twin that checks the kernel itself, and GPU
7
- searches that find counterexamples or the invariants a proof needs.
8
-
9
- You write the rule once with typed builders and state what must always hold. The library proves it by induction (or
10
- shows a real run that breaks it), and the same rule runs as an interpreter or ships as generated JavaScript.
5
+ An automatic prover for stateful rules: budgets, caps, rate limits, workflows, access policies. You write the rule and
6
+ the laws it must keep. gdp-wired proves them with no proof to write, shows a real run that breaks a law when it's false
7
+ (found by searching on the GPU), has every proof checked again by Lean, and ships the rule as JavaScript.
11
8
 
12
9
  ```sh
13
10
  npm install gdp-wired # or bun add gdp-wired; plain JavaScript with types, Node 20+, Bun or a browser
@@ -33,62 +30,76 @@ Forget the cap (`s.plus(c)`) and the kernel refuses the proof; a search from the
33
30
  and `examples/domains/` holds 92 rules from nine domains (finance, access control, public services, operations,
34
31
  protocols, commerce, supply chains, games, education), each checked three ways.
35
32
 
36
- ## Scope: where it replaces Bend, and where it doesn't
33
+ ## Where it fits
37
34
 
38
- gdp-wired is meant to replace Bend where it matters in practice, not to be a language: laws about **stateful rules whose
39
- state is counters, totals, maps of counters and logs summed over time**, proven automatically, with the rule shipped as
40
- code. If the checker side finds new frontiers the scope may grow; until then:
35
+ <img src="https://raw.githubusercontent.com/monotykamary/gdp-wired/main/static/venn.svg" alt="What Lean, Bend and gdp-wired each cover, and where they overlap" width="640" />
41
36
 
42
- | It expresses and proves | It does not (today) |
43
- | --- | --- |
44
- | Step machines over naturals: scalars, maps from naturals to naturals (one write per map per step), named step conditions, preconditions on params, assumptions per request | Lists, sets or strings as values: membership, subsets, equality of lists of names |
45
- | Invariants of the state and params, laws about every key of a map; step laws relating one step's before and after (`s'`, `M'[k]`: "only increases", "stays retired"), and k-step laws about k steps in a row ("two grants on one day add two", up to 16 steps) | Orderings over any number of steps ("a refund never follows a release", at any distance, needs a state flag), beyond progress |
46
- | Composition: rules side by side (`product`), taking turns (`interleave`), and one rule refining another (`refinement`: a forward simulation, the spec allowing every request it is sent), each built as a plain machine so every law above applies | Refinement where the spec needs more than one step per step of the implementation |
47
- | Laws about one key of a map at a time, or about scalars | Totals across a map's keys ("the sum of every client's holdings equals in-use") |
48
- | Histories: sums and queries over a log of requests, relations between them proven one record at a time, pruning | Unbounded recursion over data, quantifiers over sequences other than a machine's own history |
49
- | Decisions over facts: flags, one-of choices, optional facts, bounded naturals (`match` covers every case) | Datatypes beyond those encodings; division, modulo, reals |
50
- | Loops that halt by a rank or a tuple of ranks; folds over binary trees; progress laws ("some request makes this happen") | Arbitrary algorithms: there is no general recursion to prove things about |
51
- | Arithmetic: `+`, truncated `−`, `×` by a constant, `min`, `max`, `if`, products of two terms (proven by the kernel, less well by the prover) | Generated code for decisions alone (`compileJs` emits machines; a predicate emitter is planned) |
52
-
53
- Keep Bend where the specification is about **lists or sets of names** (resource scopes, capability subsets, normalization
54
- that must refine its input), where a proof needs **induction over data structures**, or where the shipped code must be
55
- the compiled proved source of an arbitrary algorithm. Use gdp-wired where the rule is a budget, a cap, a window, a
56
- workflow or a policy decision: it proves those without proof files, finds counterexample runs when they're false, and
57
- checks its own kernel in Lean.
58
-
59
- For a sense of the split: in one real codebase with 98 Bend laws and about 1,600 lines of hand proofs, the policy
60
- decisions, the compare-and-swap storage plans and the state/provider plans fit gdp-wired as it is (with names interned
61
- to numbers), and gdp-wired can prove more about storage than its Bend proofs state (no revision reuse across keys); the
62
- lifecycle machine's before/after laws are step laws (as in `release lifecycle` in `examples/`); the resource and authority checks, built on lists of names as
63
- sets, stay in Bend. `compare/` puts three rules side by side in gdp-wired, Bend and plain Lean, with line counts and
64
- check times.
65
-
66
- ## What's trusted
37
+ Lean proves anything you write the proof for. Bend runs programs that carry their proofs. gdp-wired proves one kind of
38
+ claim, laws about stateful rules, with no proof to write, and hands every proof to Lean as a theorem. Where a proof needs
39
+ more than it decides, the proof is Lean's: a function you define and prove there, which the rule then uses.
40
+ [`docs/comparison.md`](docs/comparison.md) goes through each region, compares the three side by side, and measures them
41
+ on the same rules.
42
+
43
+ ## What it proves
44
+
45
+ All of these are proven automatically, and each proof is also a Lean theorem:
46
+
47
+ - **Invariants**: a law that holds after every sequence of requests ("spend stays within the budget").
48
+ - **Maps, sets and lists of names**: laws at every key ("no day passes its cap"), sets a request carries, conditions
49
+ about every key, lists kept in order (`examples/sets.ts`, `examples/lists.ts`).
50
+ - **Step laws**: what one step does ("a balance only goes down", "a grant adds one"), and laws over several steps in a
51
+ row.
52
+ - **Histories**: sums and queries over a log of requests, like a sliding-window rate limit.
53
+ - **Orderings at any distance**: "a refund never follows a release", through ghost state that watches the rule.
54
+ - **Liveness**: "every ticket issued is served", in every run that keeps sending the requests that help, including under
55
+ weak fairness (`examples/liveness.ts`).
56
+ - **Refinement**: an implementation behaves like a simpler spec, step for step (`examples/composition.ts`).
57
+ - **Functions**: division, remainder, and your own functions, known by their facts; a function defined in Lean brings
58
+ facts proven there (`examples/functions.ts`).
59
+ - **Pure functions and two-run laws**: laws about what a function returns, and laws comparing two calls, like "the
60
+ secret never changes the answer". `examples/jev-fabric/` ports a Bend project's policy core and proves all 59 laws its
61
+ Bend proofs state.
62
+
63
+ When a law is false, the GPU search finds a real run that breaks it. When a law needs an invariant nobody wrote, the GPU
64
+ synthesizer finds one and the kernel proves it.
65
+
66
+ **What it leaves to Lean or Bend.** Two things, and neither is a gap the next feature closes: proofs the prover would
67
+ have to invent (induction over a recursive structure whose proof needs a lemma nobody stated, and arithmetic with
68
+ products of unknowns beyond multiplying two known facts; integer arithmetic with induction or with products is
69
+ undecidable), and shipping an arbitrary algorithm as its proved source (gdp-wired ships the rule as JavaScript; Bend
70
+ compiles the proved source itself). The full list of what is and isn't in scope is in
71
+ [`docs/comparison.md`](docs/comparison.md#gdp-wireds-scope-in-detail).
72
+
73
+ ## Why you can trust a proof
74
+
75
+ A small kernel checks every proof; the prover that finds them is untrusted, and the kernel's twin in Lean is proven
76
+ sound.
67
77
 
68
78
  | Part | Trusted? | What it does |
69
79
  | --- | --- | --- |
70
- | `kernel/kernel.ts`, `core/check.ts` | **Yes** | Turns a machine and its laws into goals and checks a proof tree for each: splits on linear atoms, a Fourier–Motzkin derivation at each leaf. Every rule (step machines with maps and flags, step laws and k-step laws, loops with ranks, progress, folds over trees) is a few moves of one small algebra of runs: conditions holding where a run is, a step taken from there, a conclusion read at the end or across the run. Histories become plain machines first. Refuses names a machine doesn't declare |
71
- | `kernel/prove.ts` | No | Grows the trees. If it's wrong, the kernel rejects the tree |
72
- | `lean-kernel/` | Optional | The kernel's twin in Lean 4.34.0, each rule proven sound (`Kern.sound`, `machine_sound`, `mmachine_sound`, `fmmachine_sound`, `loop_sound`, `fold_sound`, `progress_sound`, `steplaw_sound`, `kstep_sound`, `hmachine_sound`), axioms `propext`, `Classical.choice`, `Quot.sound` only. The exporter sends the kernel's real trees; tampered ones compute to false |
73
- | `search/`, `synth/`, `hw/` | No | GPU work: counterexample search over every short history, and invariant/rank synthesis. Every hit is replayed by the interpreter or proven by the kernel before it counts |
74
- | `core/dsl.ts`, `core/compose.ts`, the runtimes | No | The builders and combinators compile to the kernel's plain values; the interpreter and generated JavaScript are held to the reference evaluator by the conformance test, and the combinators to runs of their parts |
75
-
76
- How the untrusted parts are held to the trusted ones, automatically:
77
-
78
- - **Types**: the whole repo type-checks strictly (`noImplicitReturns`, `noFallthroughCasesInSwitch`,
79
- `noUncheckedIndexedAccess`), and a type-aware lint denies warnings; every switch over term and condition operations is
80
- exhaustive and ends in `unreachable()`, so a new operation fails to compile wherever it's missing.
81
- - **Conformance** (`core/conformance.test.ts`): random terms over every operation through every consumer (interpreter,
82
- generated JS, the kernel on fixed values, the GPU search's u32 twin, the old symbolic prover) against the reference
83
- evaluator, and the same cases through Lean's own `evalE` (`lean-kernel/conformance.ts`).
84
- - **Examples** (`examples/`): every law proven by the kernel and re-checked by Lean, every rule run at random through
85
- both runtimes, and every broken variant broken by a real run (a refused proof alone could be the prover's weakness).
86
- - **Tampering and mutation** (`kernel/tamper.test.ts`, `kernel/refusals.test.ts`, `kernel/mutants.ts`): real proofs
87
- broken structurally or offered for weakened goals that have a model must be rejected, every refusal the kernel makes is
88
- exercised, and every one-operator mutation of `kernel.ts` and `check.ts` is run against the tests to find what they
89
- don't notice: 239 of 261 mutants are caught, and the 22 left are equivalent (`kernel/MUTANTS.md`).
90
- - **Fuzz**: the kernel never accepts a refutation of something with a model (`kernel/kernel.test.ts`), and laws proven
91
- over histories hold on every run (`kernel/history.test.ts`).
80
+ | `kernel/kernel.ts`, `core/check.ts` | **Yes** | Turns a rule and its laws into goals, and checks a proof tree for each: case splits on linear facts, then a linear refutation at each leaf |
81
+ | `kernel/prove.ts` | No | Finds the proof trees. If it's wrong, the kernel rejects the tree |
82
+ | `lean-kernel/` | Optional | The kernel's twin in Lean 4.34.0, every rule proven sound there with the standard axioms only. The exporter sends the kernel's real trees; tampered ones compute to false |
83
+ | `research/search/`, `research/synth/`, `research/hw/` | No | The GPU searches for breaking runs, and invariant and rank synthesis. Every hit is replayed or proven before it counts |
84
+ | `core/dsl.ts`, `core/compose.ts`, the runtimes | No | The builders and combinators compile to the kernel's plain values; the runtimes are held to a reference evaluator |
85
+
86
+ The untrusted parts are held to the trusted ones by the test suite:
87
+
88
+ - **Builders**: every law, step law, flag, update and decision in the examples is run again on concrete runs, its
89
+ callback's answer computed by the builders' own arithmetic (each term carries its value beside its expression) against
90
+ what the interpreter makes of the expression that was proven (`rule.crossCheck`, `crossCheckDecision`). A builder that
91
+ writes a different expression than it means is caught, even where the kernel would still prove the law it wrote.
92
+ - **Encodings**: `fun.compare(reference)` and `rule.compare({init, step})` run the rule against the code it describes,
93
+ on drawn and edge inputs (strings, decimals and choices as themselves), field by field.
94
+ - **Types**: strict TypeScript and a lint that denies warnings; every switch over operations is exhaustive, so a new
95
+ operation fails to compile wherever it's missing.
96
+ - **Conformance**: random terms through every evaluator (interpreter, generated JavaScript, the kernel, the GPU search,
97
+ Lean's own evaluator) against the reference one.
98
+ - **Examples**: every law proven and re-checked in Lean, every rule run at random, every broken variant broken by a real
99
+ run.
100
+ - **Tampering and mutation**: broken proofs must be rejected; every one-operator change to the kernel is run against the
101
+ tests, and the few that survive are equivalent (`kernel/MUTANTS.md`).
102
+ - **Fuzz**: the kernel never accepts a refutation of something that has a model.
92
103
 
93
104
  ## Writing laws
94
105
 
@@ -133,16 +144,50 @@ const failsClosed = trial.decide('a missing fact means no trial', ({spent}) => s
133
144
  ({plan, spent, allowance}) => plan.match({free: () => spent.match({none: () => yes, some: s => allowance.match({none: () => yes, some: a => s.atLeast(a)})}), pro: () => yes}))
134
145
  ```
135
146
 
136
- - **Kinds**: `nat`, `upTo(k)`, `bool`, `oneOf(...)`, `maybe(kind)`; their assumptions (a bit is 0 or 1, a choice is in
137
- range) go to the kernel by themselves, and `encodeInput`/`encodeParams` turn runtime values into the numbers the
138
- machine reads.
147
+
148
+ - **Kinds**: `nat`, `upTo(k)`, `bool`, `oneOf(...)`, `maybe(kind)`, `int`, `decimal(places)`, `str({like, maxLength})`;
149
+ their assumptions (a bit is 0 or 1, a choice is in range) go to the kernel by themselves, and
150
+ `encodeInput`/`encodeParams` turn runtime values into the numbers the machine reads.
151
+ - **Integers and decimals**: `int` and `decimal(places)` inputs, `.ints({...})` and `.decimals(places, {...})` state;
152
+ `plus`, `minus`, `negate`, `times(k)` (k may be negative), `min`, `max`, `abs`, `isNegative`, comparisons with numbers
153
+ (a decimal's literals are scaled: `price.atMost(19.99)`). Each is two naturals underneath, so the kernel and the Lean
154
+ twin see nothing new; `rule.decode(state)` reads them back as numbers.
155
+ - **Strings**: `str({like: ['*.internal'], maxLength: 64})`. A string is its interned name, its length and a bit per
156
+ declared pattern (Cedar's glob), all computed from the string itself when the input is encoded. `s.like(pattern)`,
157
+ `s.eq('admin')` (which carries the literal's length and pattern bits, so laws reason from either), `s.isEmpty`,
158
+ `s.length`; sets of strings are carried maps keyed by `s.id`.
139
159
  - **Arithmetic** over the naturals: `plus`, `minus` (truncated), `times(k)`, `mul` (a product of two terms), `min`,
140
160
  `max`, `when(cond, a, b)`, `count(cond)`; conditions `lt`, `atMost`, `eq`, `and`, `or`, `not`, `implies`.
161
+ - **Sets and quantifiers**: `.carries(...)` for sets a request brings, `everyKey(k => ...)` (and `has`, `subsetOf`,
162
+ `disjointFrom`, `sameAs`) anywhere in a step or inside a law, `M.each(k => ...)` to write every key, and
163
+ `someKey(k => ..., witness?)` for "some key where", proven through a witness the prover finds or the one given.
164
+ - **Functions** (`.fns({perMinute: divBy(60)})`, or `fn(arity, js, {facts, lean})` for your own): read like params that
165
+ take arguments, `perMinute(t)`; the kernel knows each only by its facts (`.facts(...)`, and a library function's
166
+ own), and the Lean export takes them as the hypothesis they are, proving them where the function has a Lean
167
+ definition. A function defined and proven in Lean is how a rule reaches past what the kernel decides: `examples/functions.ts`.
168
+ - **Liveness** (`rule.leadsTo(name, {from, to, rank, helpful})`, `proveLeadsTo`): whenever `from` holds, `to` holds later in
169
+ every run that takes each helpful request infinitely often; proven by a rank no step raises while waiting and each
170
+ helpful request lowers in its region. `rank` may return several terms (compared in order) and `helpful` may be a list
171
+ of `{when, request}`; a request names only the inputs it fixes (`examples/liveness.ts`: batches, a history with a
172
+ query, a machine with sets).
173
+ - **Pure functions** (`fun(name).inputs(...).returns(env => ({...}))`): each field of the result a term or a condition;
174
+ `f.law(name, given, then)` is a decision about the terms it returns and `f.run(values)` runs those terms compiled. A
175
+ decision assumes the functions' facts where it reads them.
176
+ - **Two runs** (`r.relate(name, (a, b) => given, (a, b) => then)`, or `f.relate(...)` with both results): a decision
177
+ about two requests under the same params, for noninterference ("requests that differ only in a secret are decided
178
+ alike"); the second request's inputs show as `b.<input>` in a counterexample.
141
179
  - **Loops** (`loop(...)`): state, `while`, `body`, a `rank` (or a tuple of ranks) that must drop each turn.
142
- - **Composition** (`gdp-wired/compose`): `product(a, b)`, `interleave(a, b)` and `refinement(impl, spec, {relation, witness})`
180
+ - **Lean** (`gdp-wired/lean`): `toLean([{id, machine, laws, stepLaws}, ...])` writes the proofs as a Lean file, and
181
+ `leanProject(file)` a Lake project with the twin, so `lake build` checks them. Each law comes as the twin's theorem and
182
+ as a plain statement over named variables, e.g. `theorem budget.law0 (h : SReach budget_machine ρ μ) : ρ «s» ≤
183
+ (ρ «b» + ρ «m»)`, for a Lean development to cite.
184
+ - **Composition** (`gdp-wired/compose`): `product(a, b)`, `interleave(a, b)`, `refinement(impl, spec, {relation, witness, stutter})`,
185
+ `steps(spec, k)` (k spec steps as one, each skippable) and `ghost(m, {name: ever(...) | count(...) | total(m, map, name)})`
143
186
  build plain machines from machines (each side's state named `a_x`, `b_x`). A refinement's laws are the relation, proven
144
187
  by induction, and its step law says the spec allows the request `witness` sends it at each of the implementation's:
145
- `examples/composition.ts` proves a meter that counts down refines one that counts up, and refuses two broken ones.
188
+ `examples/composition.ts` proves a meter that counts down refines one that counts up, and refuses two broken ones;
189
+ a two-phase meter refines it with stuttering; an escrow never refunds after a release (at any distance); and the
190
+ connection pool's in-use count is the total of every client's holdings.
146
191
  - **Running it**: `rule.interpreter()` is persistent (searches branch from any state); `rule.compiled()` is what an app
147
192
  ships: maps and the log updated in place, so a step costs the same however long the history. Both refuse params and
148
193
  inputs that aren't naturals below 2^53 and any value past 2^53 − 1, where the proofs over the naturals stop describing
@@ -154,33 +199,25 @@ On Modal CPUs (2 vCPU), generated JavaScript, the answers checked against the in
154
199
 
155
200
  | Machine | 1k | 10k | 100k | 1M |
156
201
  | --- | --- | --- | --- | --- |
157
- | Per-key caps (`perf/grant-scale.ts`), requests | 0.3 ms | 2.5 ms | 29 ms | 267 ms |
158
- | Window limit, pruned (`perf/window-scale.ts`), events | 0.8 ms | 3.1 ms | 16 ms | 136 ms |
202
+ | Per-key caps (`research/perf/grant-scale.ts`), requests | 0.3 ms | 2.5 ms | 29 ms | 267 ms |
203
+ | Window limit, pruned (`research/perf/window-scale.ts`), events | 0.8 ms | 3.1 ms | 16 ms | 136 ms |
159
204
 
160
205
  Pruned, the window limit holds about 35 records whatever the event count; unpruned, each request scans every record kept
161
206
  (76 ms at 10k events). The interpreter copies its maps each step (it's the reference, and searches branch from it):
162
207
  1.6 s for 10k requests of the caps rule.
163
208
 
164
- A brute-force enumeration of 2^31 worlds (`enum/`, `perf/bend-enum/`), the same predicate as Bend programs and as a
165
- Handwired word program: on an M2, Bend takes 985 ms sequential, 221 ms parallel and 153 ms on Metal (whole process,
166
- about 130 ms of it device setup); ours takes 33 ms warm on WebGPU, with about 115 ms of device setup first. On an NVIDIA
167
- L4 ours takes 5 ms warm; Bend's CUDA build compiled, but its GPU runs hung in our tests (its CPU builds ran: 109 ms
168
- parallel).
209
+ Measured against Bend and Lean on the same three rules ([`compare/`](compare/README.md)), gdp-wired's proofs are one
210
+ or two lines and check in 25–44 ms, against 74–117 lines of hand proof and 137–161 ms in Bend and 8–30 lines and
211
+ 488–571 ms in Lean. Running each tool's proved code over 100,000 requests (whole command, Modal, 2 CPUs):
169
212
 
170
- ## Compared with Bend and Lean
213
+ | | budget | caps | window |
214
+ | --- | --- | --- | --- |
215
+ | gdp-wired | 44 ms | 59 ms | 2.6 s |
216
+ | Bend | 92 s | 75 s | over 300 s |
217
+ | Lean | 8 ms | 205 ms | 8.7 s |
171
218
 
172
- `compare/` writes three rules (a budget, per-key caps, a sliding-window limit) in gdp-wired, in Bend 2 and in plain Lean
173
- 4, each checked by its own tool on Modal, each also handed the same bug to reject. From `compare/README.md`:
174
-
175
- | | Rule lines | Law lines | Proof lines | Check (median, whole command) |
176
- | --- | --- | --- | --- | --- |
177
- | gdp-wired | 5–7 | 1–4 | 1–2 (`proveRule`) | 25–44 ms |
178
- | Bend 2 | 39–98 (25–50 of them proof evidence in the rule) | 5–26 | 74–117 | 137–161 ms (`--verdict` 167–178 ms) |
179
- | Lean 4 (by hand) | 4–15 | 1–4 | 8–30 | 488–571 ms |
180
-
181
- Most of each time is the tool's startup (17, 134 and 453 ms). gdp-wired's kernel trees for the same rules re-check in
182
- its Lean twin in 1.35 s. What each proves, what each ships at runtime, and where each is the better tool are in
183
- `compare/README.md`.
219
+ The proved Bend and Lean rules keep lists and scan them; gdp-wired runs maps and a log (unpruned in this window rule).
220
+ Lean, compiled natively, wins where the state stays small. Details in [`docs/comparison.md`](docs/comparison.md).
184
221
 
185
222
  ## Layout
186
223
 
@@ -188,23 +225,22 @@ its Lean twin in 1.35 s. What each proves, what each ships at runtime, and where
188
225
  | --- | --- |
189
226
  | `core/` | Machines, the interpreter and JS compiler, the builders, composition (`compose.ts`), the linear solver and its checker, Houdini invariants, models, the first Lean export |
190
227
  | `kernel/` | The trusted kernel and the untrusted prover, with soundness fuzz (`kernel.test.ts`, `history.test.ts`) |
191
- | `lean/`, `lean-kernel/` | Lean: the derivation checker `Cert.lean`, and the kernel's twin with its exported trees |
192
- | `search/`, `hw/`, `modal-gpu/` | Counterexample search on the GPU: a hand-written WGSL kernel, and the same through the Handwired fork's word stages |
193
- | `synth/` | Invariants and ranks found on the GPU, proven by the kernel |
194
- | `next/` | Guards synthesized from laws, relational laws (migrations, tenant isolation, through `core/compose.ts`), a ReBAC audit |
195
- | `sat/` | Bit-vector laws: bit-blasted, solved, LRAT-checked in TypeScript, optionally by Lean |
196
- | `enum/`, `perf/` | The enumeration, and the benchmarks |
197
- | `examples/` | The builders at work: `domains/` (92 rules across nine domains, each with laws and broken variants), `harness.ts` (the triple check), `readme.ts`, `budget.ts`, `composition.ts` |
228
+ | `lean-kernel/` | The kernel's Lean twin, its soundness proofs, and the exported trees |
229
+ | `examples/` | The builders at work: `domains/` (92 rules across nine domains, each with laws and broken variants), `harness.ts` (the triple check), `readme.ts`, `budget.ts`, `composition.ts`, `sets.ts`, `quantifiers.ts`, `functions.ts`, `lists.ts`, `sizes.ts`, `liveness.ts`, and `jev-fabric/` (a Bend project's 59 proven laws, ported) |
230
+ | `docs/` | The comparison with Bend and Lean, and the full scope |
198
231
  | `compare/` | The same three rules in gdp-wired, Bend and plain Lean, checked and measured |
199
- | `spec.ts`, `gdp.ts`, `circuit.ts`, `laws.ts` | The audit path: gdp-ts predicates written once, as trusted provers per request and as a Handwired circuit that decides every (user, project) pair at once |
232
+ | `research/` | Everything around the library ([`research/README.md`](research/README.md)): the GPU searches (`search/`, `hw/`, `modal-gpu/`), invariant and rank synthesis (`synth/`), relational laws and guard synthesis (`next/`), bit-vector laws (`sat/`), the enumeration and benchmarks (`enum/`, `gpu/`, `perf/`), the first Lean checker (`lean/`) and the first prototype, the audit path (`audit/`) |
200
233
  | `modal/` | Everything above as Modal jobs: `suite.json` is the regression suite |
234
+ | `scripts/` | The package build, the smoke test of the packed tarball, the Lean library generator, the diagram |
201
235
  | `vendor/handwired` | [monotykamary/handwired](https://github.com/monotykamary/handwired), branch `u32-words`: Cheng Lou's Handwired with exact u32 word circuits and word stages in loop programs |
202
236
  | `vendor/gdp-ts` | [rauchg/gdp-ts](https://github.com/rauchg/gdp-ts) |
203
237
 
238
+ The npm package is built from `core/`, `kernel/` and `lean-kernel/` alone; nothing in it imports `research/`.
239
+
204
240
  ## Running
205
241
 
206
242
  The npm package is the library: the builders (`gdp-wired`), the runtimes (`gdp-wired/machine`), composition
207
- (`gdp-wired/compose`), the kernel
243
+ (`gdp-wired/compose`), the Lean export (`gdp-wired/lean`), the kernel
208
244
  (`gdp-wired/kernel`), the prover (`gdp-wired/prove`), the derivation checker and solver (`gdp-wired/check`,
209
245
  `gdp-wired/solver`). Everything else here (examples, comparisons, the Lean twin, the GPU searches) runs from a clone and
210
246
  needs [Bun](https://bun.sh); Lean 4.34.0 for `lean/` and `lean-kernel/`; a WebGPU runtime (Chrome, or Dawn's node
@@ -218,7 +254,7 @@ bun test core/compile.test.ts core/runtime.test.ts kernel/history.test.ts
218
254
  cd kernel && bun run-kernel.ts && bun test kernel.test.ts
219
255
  ```
220
256
 
221
- The whole suite (110 jobs, about 15 minutes of wall time) runs on [Modal](https://modal.com):
257
+ The whole suite (122 jobs, about 20 minutes of wall time) runs on [Modal](https://modal.com):
222
258
 
223
259
  ```sh
224
260
  modal run modal/run.py --jobs modal/suite.json --out runs/modal/suite && sh modal/reap.sh
@@ -1,5 +1,5 @@
1
1
  import { type C, type E, type Law, type Machine } from './machine.js';
2
- import type { StepLaw } from '../kernel/kernel.js';
2
+ import { type StepLaw } from '../kernel/kernel.js';
3
3
  export declare const side: (m: Machine, prefix: "a_" | "b_", c: C) => C;
4
4
  export declare function product(a: Machine, b: Machine, opts?: {
5
5
  name?: string;
@@ -15,10 +15,24 @@ export declare function interleave(a: Machine, b: Machine, opts?: {
15
15
  export declare function refinement(impl: Machine, spec: Machine, opts: {
16
16
  relation: Law[];
17
17
  witness: Record<string, E>;
18
+ stutter?: C;
18
19
  name?: string;
19
20
  }): {
20
21
  machine: Machine;
21
22
  laws: Law[];
22
23
  stepLaws: StepLaw[];
23
24
  };
25
+ export declare function refinementSides(impl: Machine, spec: Machine): {
26
+ impl: Machine;
27
+ spec: Machine;
28
+ };
24
29
  export declare function agree(a: Machine): Law[];
30
+ export declare function steps(m: Machine, k: number): Machine;
31
+ export type Ghost = {
32
+ start: E;
33
+ step: E;
34
+ };
35
+ export declare function ghost(m: Machine, vars: Record<string, Ghost>): Machine;
36
+ export declare const ever: (name: string, cond: C) => Ghost;
37
+ export declare const count: (name: string, cond: C) => Ghost;
38
+ export declare function total(m: Machine, map: string, name: string): Ghost;
@@ -3,18 +3,24 @@
3
3
  // is trusted: a proof is about the machine built, and the combinators are tested against runs of their parts.
4
4
  // product(a, b) both step on every request (their params and inputs shared by name)
5
5
  // interleave(a, b) one of them steps on each request, `turn` picking which
6
- // refinement(impl, spec, {relation, witness})
6
+ // refinement(impl, spec, {relation, witness, stutter})
7
7
  // impl refines spec: the spec, sent the request `witness` names for each of impl's, always allows it,
8
- // and `relation` holds of the two states at every step (a forward simulation)
8
+ // and `relation` holds of the two states at every step (a forward simulation); where `stutter`
9
+ // holds, impl's step is one the spec doesn't see, and the spec stays
10
+ // ghost(m, vars) state that watches the machine without changing it: what happened before ("ever", "count") and
11
+ // totals across a map's keys ("total"), for laws about whole histories
12
+ // steps(m, k) k steps of m as one: the spec of a refinement that takes several steps for one of impl's
9
13
  // Each side's state, maps and flags are renamed a_x and b_x; params and inputs keep their names.
10
14
  import { all, any, bit, not, same, unreachable, x } from "./machine.js";
15
+ import { substC, substE } from "../kernel/kernel.js";
16
+ import { nameQuantifiers } from "./dsl.js";
11
17
  function rename(e, prefix, m, inputs) {
12
18
  switch (e.op) {
13
19
  case 'var': return m.params.includes(e.name) ? e : m.inputs.includes(e.name) ? (inputs[e.name] ?? e) : x(`${prefix}${e.name}`);
14
20
  case 'const': return e;
15
21
  case 'mul': return { ...e, a: rename(e.a, prefix, m, inputs) };
16
22
  case 'if': return { op: 'if', c: renameC(e.c, prefix, m, inputs), then: rename(e.then, prefix, m, inputs), else: rename(e.else, prefix, m, inputs) };
17
- case 'get': return { op: 'get', map: `${prefix}${e.map}`, key: rename(e.key, prefix, m, inputs) };
23
+ case 'get': return { op: 'get', map: m.inputMaps?.includes(e.map) ? e.map : `${prefix}${e.map}`, key: rename(e.key, prefix, m, inputs) };
18
24
  case 'add':
19
25
  case 'sub':
20
26
  case 'min':
@@ -30,6 +36,8 @@ function renameC(c, prefix, m, inputs) {
30
36
  return { op: 'not', x: renameC(c.x, prefix, m, inputs) };
31
37
  if (c.op === 'and' || c.op === 'or')
32
38
  return { op: c.op, xs: c.xs.map(y => renameC(y, prefix, m, inputs)) };
39
+ if (c.op === 'all')
40
+ return { op: 'all', key: `${prefix}${c.key}`, c: renameC(c.c, prefix, m, inputs) }; // the key renamed with its side
33
41
  return { ...c, a: rename(c.a, prefix, m, inputs), b: rename(c.b, prefix, m, inputs) };
34
42
  }
35
43
  // A condition (or law) of one side, read in the composed machine.
@@ -46,6 +54,8 @@ function renamed(m, p, inputs, stays) {
46
54
  flags: Object.fromEntries(Object.entries(m.step.flags ?? {}).map(([n, c]) => [`${p}${n}`, renameC(c, p, m, inputs)])),
47
55
  state: Object.fromEntries(Object.entries(m.step.state ?? {}).map(([n, e]) => [`${p}${n}`, keep(rename(e, p, m, inputs), x(`${p}${n}`))])),
48
56
  maps: Object.fromEntries(Object.entries(m.step.maps ?? {}).map(([n, w]) => {
57
+ if ('each' in w)
58
+ return [`${p}${n}`, { each: `${p}${w.each}`, value: keep(rename(w.value, p, m, inputs), { op: 'get', map: `${p}${n}`, key: x(`${p}${w.each}`) }) }];
49
59
  const key = rename(w.key, p, m, inputs);
50
60
  return [`${p}${n}`, { key, value: keep(rename(w.value, p, m, inputs), { op: 'get', map: `${p}${n}`, key }) }];
51
61
  })),
@@ -57,6 +67,8 @@ const join = (a, b) => ({
57
67
  state: { ...a.state, ...b.state }, maps: { ...a.maps, ...b.maps },
58
68
  step: { flags: { ...a.step.flags, ...b.step.flags }, state: { ...a.step.state, ...b.step.state }, maps: { ...a.step.maps, ...b.step.maps } },
59
69
  });
70
+ // The maps requests carry: shared by name, as inputs are.
71
+ const carriedBy = (...ms) => { const names = [...new Set(ms.flatMap(m => m.inputMaps ?? []))]; return names.length ? { inputMaps: names } : {}; };
60
72
  const conj = (cs) => { const xs = cs.filter((c) => c !== undefined); return xs.length ? all(...xs) : undefined; };
61
73
  // a and b stepped by the same requests. `bInputs` rewrites what b sees (other tenants' requests as no-ops, say).
62
74
  export function product(a, b, opts = {}) {
@@ -66,7 +78,7 @@ export function product(a, b, opts = {}) {
66
78
  name: opts.name ?? `${a.name} | ${b.name}`,
67
79
  params: [...new Set([...a.params, ...b.params, ...(opts.params ?? [])])],
68
80
  inputs: [...new Set([...a.inputs, ...b.inputs, ...(opts.inputs ?? [])])],
69
- ...join(A, B),
81
+ ...join(A, B), ...carriedBy(a, b),
70
82
  ...(assume ? { assume } : {}),
71
83
  ...(pre ? { pre } : {}),
72
84
  };
@@ -84,7 +96,7 @@ export function interleave(a, b, opts = {}) {
84
96
  name: opts.name ?? `${a.name} || ${b.name}`,
85
97
  params: [...new Set([...a.params, ...b.params])],
86
98
  inputs: [...new Set([...a.inputs, ...b.inputs, turn])],
87
- ...join(A, B),
99
+ ...join(A, B), ...carriedBy(a, b),
88
100
  assume: all(bit(turn), ...(A.assume ? [any(not(isA), A.assume)] : []), ...(B.assume ? [any(not(isB), B.assume)] : [])),
89
101
  ...(pre ? { pre } : {}),
90
102
  };
@@ -98,13 +110,16 @@ export function interleave(a, b, opts = {}) {
98
110
  export function refinement(impl, spec, opts) {
99
111
  if (!spec.inputs.every(i => i in opts.witness))
100
112
  throw new Error(`refinement: name every input of ${spec.name} (${spec.inputs.join(', ')})`);
101
- const A = renamed(impl, 'a_', {}), B = renamed(spec, 'b_', opts.witness);
102
- const allowedBy = B.assume;
113
+ if ((spec.inputMaps ?? []).some(mp => !(impl.inputMaps ?? []).includes(mp)))
114
+ throw new Error(`refinement: a map ${spec.name}'s requests carry is one ${impl.name}'s carry too (shared by name)`);
115
+ const A = renamed(impl, 'a_', {}), B = renamed(spec, 'b_', opts.witness, opts.stutter);
116
+ // A step the spec doesn't see needs no request it allows.
117
+ const allowedBy = B.assume && opts.stutter ? any(opts.stutter, B.assume) : B.assume;
103
118
  const machine = {
104
119
  name: opts.name ?? `${impl.name} refines ${spec.name}`,
105
120
  params: [...new Set([...impl.params, ...spec.params])],
106
121
  inputs: impl.inputs,
107
- ...join(A, B),
122
+ ...join(A, B), ...carriedBy(impl, spec),
108
123
  ...(A.assume ? { assume: A.assume } : {}),
109
124
  ...(impl.pre ? { pre: impl.pre } : {}),
110
125
  };
@@ -112,6 +127,16 @@ export function refinement(impl, spec, opts) {
112
127
  const stepLaws = allowedBy ? [{ name: `${spec.name} allows the request it is sent`, then: allowedBy }] : [];
113
128
  return { machine, laws, stepLaws };
114
129
  }
130
+ // A refinement's two sides as machines in its composite's names (for lean-kernel/Refine.lean, which builds the composite
131
+ // from them): the implementation's parts named a_x, the spec's b_x, the spec's inputs b_<input> (which the composite
132
+ // never reads: the witness stands for them there).
133
+ export function refinementSides(impl, spec) {
134
+ const named = (m, p, inputs, inputNames) => {
135
+ const R = renamed(m, p, inputs);
136
+ return { name: m.name, params: m.params, inputs: inputNames, state: R.state, maps: R.maps, step: R.step, ...(m.inputMaps?.length ? { inputMaps: m.inputMaps } : {}), ...(R.assume ? { assume: R.assume } : {}), ...(m.pre ? { pre: m.pre } : {}) };
137
+ };
138
+ return { impl: named(impl, 'a_', {}, impl.inputs), spec: named(spec, 'b_', Object.fromEntries(spec.inputs.map(i => [i, x(`b_${i}`)])), spec.inputs.map(i => `b_${i}`)) };
139
+ }
115
140
  // "a and b agree" in a product of a machine with another of its shape: every scalar equal, every map equal at every key.
116
141
  export function agree(a) {
117
142
  return [
@@ -119,3 +144,102 @@ export function agree(a) {
119
144
  ...Object.keys(a.maps ?? {}).map(n => ({ name: `a.${n}[k] = b.${n}[k]`, holds: same({ op: 'get', map: `a_${n}`, key: x('k') }, { op: 'get', map: `b_${n}`, key: x('k') }), forall: 'k' })),
120
145
  ];
121
146
  }
147
+ // --- several steps as one --------------------------------------------------------------------------------------------
148
+ // k steps of m in a row as one step: the j-th request read as <input>@s<j> (j from 1), each step skipped where its input
149
+ // skip@s<j> is 1. Each step reads the state the one before it left (its flags decided there, every map read over the
150
+ // writes before), so several writes to one map become one write at every key, its key compared with theirs (which the
151
+ // runtimes decide on breakpoints). Its assumption: each step not skipped is one m allows. As the spec of a refinement,
152
+ // the witness names each step's request and when it is skipped.
153
+ export function steps(m, k) {
154
+ if (!Number.isInteger(k) || k < 1)
155
+ throw new Error('steps: k is a whole number at least 1');
156
+ if (m.history || m.inputMaps?.length)
157
+ throw new Error(`steps: ${m.name} has a history or maps a request carries (not taken here)`);
158
+ const at = (name, j) => `${name}@s${j}`;
159
+ let vars = Object.fromEntries(Object.keys(m.state).map(n => [n, x(n)]));
160
+ let maps = Object.fromEntries(Object.keys(m.maps ?? {}).map(map => [map, (key) => ({ op: 'get', map, key })]));
161
+ const allowed = [], written = new Set();
162
+ for (let j = 1; j <= k; j++) {
163
+ const skip = same(x(at('skip', j)), { op: 'const', value: 1 });
164
+ const sub = { vars: { ...vars, ...Object.fromEntries(m.inputs.map(i => [i, x(at(i, j))])) }, maps, flags: {} };
165
+ for (const [f, c] of Object.entries(m.step.flags ?? {}))
166
+ sub.flags[f] = substC(c, sub);
167
+ if (m.assume)
168
+ allowed.push(any(skip, substC(m.assume, sub)));
169
+ const kept = (now, old) => ({ op: 'if', c: skip, then: old, else: now });
170
+ const nextVars = { ...vars };
171
+ for (const [n, e] of Object.entries(m.step.state ?? {}))
172
+ nextVars[n] = kept(substE(e, sub), vars[n]);
173
+ const nextMaps = { ...maps };
174
+ for (const [map, w] of Object.entries(m.step.maps ?? {})) {
175
+ written.add(map);
176
+ const old = maps[map];
177
+ if ('each' in w)
178
+ nextMaps[map] = (key) => kept(substE(w.value, { ...sub, vars: { ...sub.vars, [w.each]: key } }), old(key));
179
+ else {
180
+ const wk = substE(w.key, sub), wv = substE(w.value, sub);
181
+ nextMaps[map] = (key) => kept({ op: 'if', c: same(key, wk), then: wv, else: old(key) }, old(key));
182
+ }
183
+ }
184
+ vars = nextVars;
185
+ maps = nextMaps;
186
+ }
187
+ const each = 'k$steps';
188
+ return nameQuantifiers({
189
+ name: `${m.name}, ${k} steps at once`, params: m.params,
190
+ inputs: Array.from({ length: k }, (_, i) => [...m.inputs.map(n => at(n, i + 1)), at('skip', i + 1)]).flat(),
191
+ state: m.state, ...(m.maps ? { maps: m.maps } : {}),
192
+ step: {
193
+ state: Object.fromEntries(Object.keys(m.step.state ?? {}).map(n => [n, vars[n]])),
194
+ ...(written.size ? { maps: Object.fromEntries([...written].map(map => [map, { each, value: maps[map](x(each)) }])) } : {}),
195
+ },
196
+ assume: all(...Array.from({ length: k }, (_, i) => bit(at('skip', i + 1))), ...allowed),
197
+ ...(m.pre ? { pre: m.pre } : {}),
198
+ });
199
+ }
200
+ export function ghost(m, vars) {
201
+ const taken = [...m.params, ...m.inputs, ...Object.keys(m.state), ...Object.keys(m.maps ?? {}), ...Object.keys(m.step.flags ?? {})];
202
+ for (const name of Object.keys(vars))
203
+ if (taken.includes(name))
204
+ throw new Error(`ghost: ${name} is already a name of ${m.name}`);
205
+ if (m.history)
206
+ throw new Error(`${m.name}: ghosts go on a machine without a history (its sums are ghosts of that kind already)`);
207
+ return {
208
+ ...m,
209
+ state: { ...m.state, ...Object.fromEntries(Object.entries(vars).map(([n, g]) => [n, g.start])) },
210
+ step: { ...m.step, state: { ...m.step.state, ...Object.fromEntries(Object.entries(vars).map(([n, g]) => [n, g.step])) } },
211
+ };
212
+ }
213
+ // Whether `cond` has held at some step so far (read where the step reads: the state before, the request, the flags).
214
+ export const ever = (name, cond) => ({ start: { op: 'const', value: 0 }, step: { op: 'if', c: cond, then: { op: 'const', value: 1 }, else: x(name) } });
215
+ // How many steps so far `cond` held at.
216
+ export const count = (name, cond) => ({ start: { op: 'const', value: 0 }, step: { op: 'add', a: x(name), b: { op: 'if', c: cond, then: { op: 'const', value: 1 }, else: { op: 'const', value: 0 } } } });
217
+ // The total of a map over every key: the map must start at 0 everywhere (so finitely many keys are nonzero), and each
218
+ // step writes one key, so the total moves by the value written less the value it replaces. That difference is pushed
219
+ // through the written term where its shape gives it (old + d adds d; old ∸ d takes min(d, old); an if moves each way
220
+ // by its branch's), so a proof needs no fact about the total beyond the law it is about; any other term falls back to
221
+ // total ∸ old + new (exact, since the total is at least any one cell).
222
+ export function total(m, map, name) {
223
+ if ((m.maps ?? {})[map] !== 0)
224
+ throw new Error(`total: ${map} must start at 0 at every key`);
225
+ const w = m.step.maps?.[map];
226
+ if (!w)
227
+ return { start: { op: 'const', value: 0 }, step: x(name) };
228
+ if ('each' in w)
229
+ throw new Error(`total: ${map} is written at every key at once, which no one step's difference describes`);
230
+ const old = { op: 'get', map, key: w.key }, t = x(name), same = (a) => JSON.stringify(a) === JSON.stringify(old);
231
+ const moved = (e) => {
232
+ if (same(e))
233
+ return t;
234
+ if (e.op === 'add' && same(e.a))
235
+ return { op: 'add', a: t, b: e.b };
236
+ if (e.op === 'add' && same(e.b))
237
+ return { op: 'add', a: t, b: e.a };
238
+ if (e.op === 'sub' && same(e.a))
239
+ return { op: 'sub', a: t, b: { op: 'min', a: e.b, b: old } };
240
+ if (e.op === 'if')
241
+ return { op: 'if', c: e.c, then: moved(e.then), else: moved(e.else) };
242
+ return { op: 'add', a: { op: 'sub', a: t, b: old }, b: e };
243
+ };
244
+ return { start: { op: 'const', value: 0 }, step: moved(w.value) };
245
+ }