@bjornpagen/bumbledb 0.1.0 → 0.3.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/COOKBOOK.md +1450 -0
- package/README.md +69 -30
- package/dist/closed.d.ts +157 -38
- package/dist/closed.d.ts.map +1 -1
- package/dist/closed.js +232 -47
- package/dist/closed.js.map +1 -1
- package/dist/count.d.ts +64 -17
- package/dist/count.d.ts.map +1 -1
- package/dist/count.js +37 -14
- package/dist/count.js.map +1 -1
- package/dist/db.d.ts +27 -8
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +33 -10
- package/dist/db.js.map +1 -1
- package/dist/exhume.d.ts +12 -11
- package/dist/exhume.d.ts.map +1 -1
- package/dist/exhume.js +7 -6
- package/dist/exhume.js.map +1 -1
- package/dist/face.d.ts +135 -30
- package/dist/face.d.ts.map +1 -1
- package/dist/face.js +44 -19
- package/dist/face.js.map +1 -1
- package/dist/fields.d.ts +130 -130
- package/dist/fields.d.ts.map +1 -1
- package/dist/fields.js +60 -94
- package/dist/fields.js.map +1 -1
- package/dist/index.d.ts +29 -24
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -14
- package/dist/index.js.map +1 -1
- package/dist/law.d.ts +224 -0
- package/dist/law.d.ts.map +1 -0
- package/dist/law.js +224 -0
- package/dist/law.js.map +1 -0
- package/dist/lower.d.ts +17 -7
- package/dist/lower.d.ts.map +1 -1
- package/dist/lower.js +66 -24
- package/dist/lower.js.map +1 -1
- package/dist/marshal.d.ts +36 -21
- package/dist/marshal.d.ts.map +1 -1
- package/dist/marshal.js +41 -25
- package/dist/marshal.js.map +1 -1
- package/dist/native.d.ts +6 -2
- package/dist/native.d.ts.map +1 -1
- package/dist/native.js +2 -2
- package/dist/native.js.map +1 -1
- package/dist/query/atom.d.ts +370 -174
- package/dist/query/atom.d.ts.map +1 -1
- package/dist/query/atom.js +75 -172
- package/dist/query/atom.js.map +1 -1
- package/dist/query/lower.d.ts +312 -109
- package/dist/query/lower.d.ts.map +1 -1
- package/dist/query/lower.js +906 -387
- package/dist/query/lower.js.map +1 -1
- package/dist/query/predicate.d.ts +70 -88
- package/dist/query/predicate.d.ts.map +1 -1
- package/dist/query/predicate.js +118 -72
- package/dist/query/predicate.js.map +1 -1
- package/dist/query/run.d.ts +19 -16
- package/dist/query/run.d.ts.map +1 -1
- package/dist/query/run.js +31 -24
- package/dist/query/run.js.map +1 -1
- package/dist/query/scope.d.ts +178 -127
- package/dist/query/scope.d.ts.map +1 -1
- package/dist/query/scope.js +111 -108
- package/dist/query/scope.js.map +1 -1
- package/dist/query/select.d.ts +102 -80
- package/dist/query/select.d.ts.map +1 -1
- package/dist/query/select.js +39 -34
- package/dist/query/select.js.map +1 -1
- package/dist/relation.d.ts +48 -38
- package/dist/relation.d.ts.map +1 -1
- package/dist/relation.js +24 -19
- package/dist/relation.js.map +1 -1
- package/dist/schema.d.ts +41 -3
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +34 -9
- package/dist/schema.js.map +1 -1
- package/dist/spec.d.ts +7 -5
- package/dist/spec.d.ts.map +1 -1
- package/dist/spec.js.map +1 -1
- package/dist/statements.d.ts +91 -36
- package/dist/statements.d.ts.map +1 -1
- package/dist/statements.js +42 -12
- package/dist/statements.js.map +1 -1
- package/package.json +4 -3
- package/src/closed.ts +448 -95
- package/src/count.ts +112 -18
- package/src/db.ts +45 -17
- package/src/exhume.ts +12 -11
- package/src/face.ts +211 -41
- package/src/fields.ts +199 -234
- package/src/index.ts +78 -61
- package/src/law.ts +519 -0
- package/src/lower.ts +82 -27
- package/src/marshal.ts +48 -30
- package/src/native.ts +8 -4
- package/src/query/atom.ts +525 -374
- package/src/query/lower.ts +1469 -538
- package/src/query/predicate.ts +208 -161
- package/src/query/run.ts +35 -25
- package/src/query/scope.ts +264 -218
- package/src/query/select.ts +168 -93
- package/src/relation.ts +47 -47
- package/src/schema.ts +69 -18
- package/src/spec.ts +7 -5
- package/src/statements.ts +124 -41
- package/dist/brand.d.ts +0 -59
- package/dist/brand.d.ts.map +0 -1
- package/dist/brand.js +0 -47
- package/dist/brand.js.map +0 -1
- package/src/brand.ts +0 -82
package/COOKBOOK.md
ADDED
|
@@ -0,0 +1,1450 @@
|
|
|
1
|
+
# The cookbook — modeling intuition as schemas, in TypeScript
|
|
2
|
+
|
|
3
|
+
The bumbledb engine's 29 cookbook recipes (`bumbledb/docs/cookbook.md`),
|
|
4
|
+
translated to this SDK's structural API. **This document is illustrative,
|
|
5
|
+
never normative**: where a recipe and an engine architecture chapter disagree,
|
|
6
|
+
the chapter wins (`docs/architecture/README.md` rule 5) — the SDK is the same
|
|
7
|
+
theory in another skin, and the engine cookbook's deference chain
|
|
8
|
+
(`10-data-model.md`, `30-dependencies.md`, `20-query-ir.md`, `70-api.md`)
|
|
9
|
+
applies here unchanged.
|
|
10
|
+
|
|
11
|
+
Every `ts` fence below compiles VERBATIM against the current SDK —
|
|
12
|
+
`ts/test/cookbook-doc.test.ts` extracts each recipe's fences from this
|
|
13
|
+
document mechanically at test time and type-checks them against
|
|
14
|
+
`src/index.ts` (the TS twin of the Rust cookbook's doc-sync pin), so an
|
|
15
|
+
edit here whose code stops compiling fails the build. The runtime half of
|
|
16
|
+
the claim is `ts/test/cookbook.test.ts`'s, over compiled copies of the
|
|
17
|
+
recipes: each schema is constructed through the public surface, admitted
|
|
18
|
+
on a real store (the engine's schema validation is the acceptance
|
|
19
|
+
judgment), its fingerprint asserted stable across a reopen AND equal to
|
|
20
|
+
the per-recipe cross-host golden the Rust cookbook suite also pins
|
|
21
|
+
(`test/fixtures/cookbook-fingerprints.txt` — the two cookbooks teach one
|
|
22
|
+
theory per recipe number, provably), and every query snippet lowered
|
|
23
|
+
through `db.prepare` (the engine's own IR validation).
|
|
24
|
+
|
|
25
|
+
Guarantee labels that name Lean results cite the checked spec in the engine
|
|
26
|
+
repo's `lean/` by theorem name, exactly as the engine cookbook does
|
|
27
|
+
(`scripts/spec-census.sh` verifies every citation resolves); the label always
|
|
28
|
+
names any additional Rust premise.
|
|
29
|
+
|
|
30
|
+
Everything below imports from the one package entry:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import {
|
|
34
|
+
ALLEN,
|
|
35
|
+
Db,
|
|
36
|
+
abandon,
|
|
37
|
+
allen,
|
|
38
|
+
bool,
|
|
39
|
+
bytes,
|
|
40
|
+
closed,
|
|
41
|
+
contained,
|
|
42
|
+
eq,
|
|
43
|
+
i64,
|
|
44
|
+
interval,
|
|
45
|
+
key,
|
|
46
|
+
lt,
|
|
47
|
+
mirrors,
|
|
48
|
+
not,
|
|
49
|
+
on,
|
|
50
|
+
pointIn,
|
|
51
|
+
program,
|
|
52
|
+
query,
|
|
53
|
+
relation,
|
|
54
|
+
schema,
|
|
55
|
+
str,
|
|
56
|
+
u64
|
|
57
|
+
} from "@bjornpagen/bumbledb"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## The class laws — the statements type the columns
|
|
61
|
+
|
|
62
|
+
Relation declarations are pure structure — `kind`, `width`, `element`,
|
|
63
|
+
`fresh`, nothing else. Domains are never declared: `schema()` computes every
|
|
64
|
+
field's equivalence class FROM the statement list, by union-find across every
|
|
65
|
+
paired face (containment, `mirrors`, window targets, ψ-selected faces
|
|
66
|
+
included). Three laws govern the classes:
|
|
67
|
+
|
|
68
|
+
1. **A fresh field is a generator** and names its class by its declaration
|
|
69
|
+
coordinate (`"Attempt.id"`); a closed relation's id is a generator named
|
|
70
|
+
the same way (`"Kind.id"`).
|
|
71
|
+
2. **A generator-less class** is named by its least member coordinate in
|
|
72
|
+
relation-declaration × field-declaration order (recipe 5's
|
|
73
|
+
`"Document.payload"`), deterministic and pinned forever.
|
|
74
|
+
3. **A field in no law is bare, and bare pairs only with bare** in queries —
|
|
75
|
+
a deliberate sum-domain pointer stays legal because you simply write no
|
|
76
|
+
law over it.
|
|
77
|
+
|
|
78
|
+
The wall: at most ONE generator per class — a statement list that unifies two
|
|
79
|
+
fresh coordinates is a contradiction (two mints cannot share a carrier), a
|
|
80
|
+
schema-level compile error with a construction-time runtime twin. The
|
|
81
|
+
dividend is that the statements you already write ARE the typing: a query
|
|
82
|
+
variable first bound in one class refuses to join a field of another (compile
|
|
83
|
+
error, with the same refusal thrown at construction), and no label, link, or
|
|
84
|
+
domain declaration exists anywhere on the surface. When a recipe below says a
|
|
85
|
+
statement "types" a column, this machinery is what it means. Classes never
|
|
86
|
+
touch the fingerprint — identity hashes canonical descriptor bytes, never the
|
|
87
|
+
law-computed names.
|
|
88
|
+
|
|
89
|
+
## Foundations
|
|
90
|
+
|
|
91
|
+
## 1. The minimal interval schema
|
|
92
|
+
|
|
93
|
+
Guarantee: Lean theorem + validator/runtime premise — the pointwise key
|
|
94
|
+
enforces per-service disjointness (`lean/Bumbledb/Dependencies.lean:
|
|
95
|
+
pointwise_key_disjoint`); checked intervals supply nonempty values
|
|
96
|
+
(`lean/Bumbledb/Values.lean: interval_nonempty`).
|
|
97
|
+
|
|
98
|
+
One fact per outage window; the pointwise key is the whole temporal design.
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
const Service = relation("Service", { id: u64.fresh, name: str })
|
|
102
|
+
// The window is one value, not a (start, end) column pair: the denotation
|
|
103
|
+
// (a set of points, half-open) is what the judgments below read through.
|
|
104
|
+
// The containment below is what types `service` — the class laws.
|
|
105
|
+
const Outage = relation("Outage", { service: u64, window: interval(i64) })
|
|
106
|
+
|
|
107
|
+
const Uptime = schema("Uptime", { Service, Outage }, [
|
|
108
|
+
contained(on(Outage, "service"), on(Service, "id")),
|
|
109
|
+
// The pointwise key: per service, no two outages share a point — every
|
|
110
|
+
// pair satisfies DISJOINT. SQL:2011's WITHOUT OVERLAPS, as a theorem.
|
|
111
|
+
// `key(R, [...])` is the host flavor of the canonical arrow — this one
|
|
112
|
+
// renders `Outage(service, window) -> Outage`: the key projection
|
|
113
|
+
// determines the tuple, and the arrow closing over its own relation is
|
|
114
|
+
// what makes a key a key (the compile-pin asserts the render).
|
|
115
|
+
key(Outage, ["service", "window"])
|
|
116
|
+
])
|
|
117
|
+
|
|
118
|
+
// down at instant t — `r.vars` mints the rule's whole variable scope at
|
|
119
|
+
// once, and shorthand punning binds same-named columns:
|
|
120
|
+
const downAt = query(Uptime).rule((r) => {
|
|
121
|
+
const { service, window } = r.vars("service", "window")
|
|
122
|
+
return r
|
|
123
|
+
.match(Outage, { service, window })
|
|
124
|
+
.where(pointIn(r.param("t"), window))
|
|
125
|
+
.select("service")
|
|
126
|
+
})
|
|
127
|
+
// overlapping an incident window (one Allen mask, no operator zoo):
|
|
128
|
+
const overlapping = query(Uptime).rule((r) => {
|
|
129
|
+
const { service, window } = r.vars("service", "window")
|
|
130
|
+
return r
|
|
131
|
+
.match(Outage, { service, window })
|
|
132
|
+
.where(allen(window, ALLEN.intersects, r.param("incident")))
|
|
133
|
+
.select("service", "window")
|
|
134
|
+
})
|
|
135
|
+
// total downtime per service (the denotation's one arithmetic):
|
|
136
|
+
const downtime = query(Uptime).rule((r) => {
|
|
137
|
+
const { service, window } = r.vars("service", "window")
|
|
138
|
+
return r.match(Outage, { service, window }).select("service", r.sum(r.duration("window")))
|
|
139
|
+
})
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## 2. Discriminated unions
|
|
143
|
+
|
|
144
|
+
Guarantee: Lean theorem + validator/runtime premises — key-backed equality
|
|
145
|
+
gives unique source/target correspondence (`lean/Bumbledb/Dependencies.lean:
|
|
146
|
+
keyed_eq_unique_correspondence`); both projections must resolve to declared keys.
|
|
147
|
+
|
|
148
|
+
Sum-typed entities: a closed-relation discriminator plus per-arm child
|
|
149
|
+
relations, glued by bidirectional conditional containments.
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
// The discriminator vocabulary is a closed relation: its ground axioms are
|
|
153
|
+
// axioms, and the handle constants (`Kind.Deterministic`, bare bigints) are
|
|
154
|
+
// the literals on every surface.
|
|
155
|
+
const Kind = closed("Kind", ["Deterministic", "CustomOperator"])
|
|
156
|
+
const Task = relation("Task", { id: u64.fresh, kind: Kind.id })
|
|
157
|
+
const DeterministicGrading = relation("DeterministicGrading", { task: u64, tolerance: i64 })
|
|
158
|
+
const CustomOperatorGrading = relation("CustomOperatorGrading", { task: u64, operator: str })
|
|
159
|
+
|
|
160
|
+
const Grading = schema("Grading", { Kind, Task, DeterministicGrading, CustomOperatorGrading }, [
|
|
161
|
+
contained(on(Task, "kind"), on(Kind, "id")), // the discriminator resolves
|
|
162
|
+
key(DeterministicGrading, ["task"]), // one arm fact per parent
|
|
163
|
+
key(CustomOperatorGrading, ["task"]),
|
|
164
|
+
// Totality (==, left to right): a Deterministic task HAS its arm fact —
|
|
165
|
+
// same commit, always. Arm validity (right to left): an arm fact's parent
|
|
166
|
+
// exists WITH that kind — composite-FK-plus-CHECK, one statement. These
|
|
167
|
+
// mirrors are also what type `task` on both arms: each lands in the
|
|
168
|
+
// "Task.id" generator class.
|
|
169
|
+
mirrors(on(Task.where({ kind: Kind.Deterministic }), "id"), on(DeterministicGrading, "task")),
|
|
170
|
+
mirrors(on(Task.where({ kind: Kind.CustomOperator }), "id"), on(CustomOperatorGrading, "task"))
|
|
171
|
+
// Exclusivity is a theorem, not a statement: one id in two arms would
|
|
172
|
+
// force `kind` to equal two handles against the fresh key on id.
|
|
173
|
+
// The executor spends the same theorem again — recipe 22's free lunch.
|
|
174
|
+
])
|
|
175
|
+
|
|
176
|
+
// Host dispatch over the discriminator is `match` — exhaustive over the
|
|
177
|
+
// sealed roster by construction (a missing arm is a compile error; an
|
|
178
|
+
// out-of-roster id throws, never misdispatches):
|
|
179
|
+
const gradedBy = (kind: bigint) =>
|
|
180
|
+
Kind.match(kind, {
|
|
181
|
+
Deterministic: () => "tolerance",
|
|
182
|
+
CustomOperator: () => "operator"
|
|
183
|
+
})
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## 3. 0..1 optional attributes
|
|
187
|
+
|
|
188
|
+
Guarantee: Lean theorem + validator/runtime premises — the child key proves at
|
|
189
|
+
most one fact (`lean/Bumbledb/Dependencies.lean: functionality_unique_witness`)
|
|
190
|
+
and containment requires its parent (`lean/Bumbledb/Dependencies.lean:
|
|
191
|
+
contains_iff_view_subset`); absence remains legal.
|
|
192
|
+
|
|
193
|
+
No nulls, anywhere. Optional data is an absent fact in a child relation; the
|
|
194
|
+
child's key plus a one-way containment *is* "nullable column", done honestly.
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
const Business = relation("Business", { id: u64.fresh, name: str })
|
|
198
|
+
const MailingAddress = relation("MailingAddress", { business: u64, line: str, city: str })
|
|
199
|
+
|
|
200
|
+
const Optionality = schema("Optionality", { Business, MailingAddress }, [
|
|
201
|
+
key(MailingAddress, ["business"]), // at most one address...
|
|
202
|
+
contained(on(MailingAddress, "business"), on(Business, "id")) // ...and only for a real business
|
|
203
|
+
// One-way containment on purpose: absence is the fact that isn't. The
|
|
204
|
+
// all-or-nothing column group (line+city together or neither) is
|
|
205
|
+
// unstatable TO VIOLATE — the fact carries both fields or does not exist.
|
|
206
|
+
])
|
|
207
|
+
|
|
208
|
+
// Negation is plain anti-join (no null branch exists in any operator):
|
|
209
|
+
const unaddressed = query(Optionality).rule((r) => {
|
|
210
|
+
const { b } = r.vars("b")
|
|
211
|
+
return r
|
|
212
|
+
.match(Business, { id: b })
|
|
213
|
+
.where(not(MailingAddress, { business: b }))
|
|
214
|
+
.select("b")
|
|
215
|
+
})
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## 4. Money
|
|
219
|
+
|
|
220
|
+
Guarantee: host discipline + validator premises — fixed-point scale and
|
|
221
|
+
currency grouping live in host domains; containments only resolve references.
|
|
222
|
+
|
|
223
|
+
Fixed-point i64 minor units. Floats are permanently refused (the ledger);
|
|
224
|
+
proration and FX are host arithmetic. `minor` sits in no law — bare, and bare
|
|
225
|
+
pairs only with bare (the class laws), so a stray join against some other
|
|
226
|
+
i64 column refuses unless a statement puts them in one class. Scale and
|
|
227
|
+
currency intent are host discipline; the closed `currency` reference is what
|
|
228
|
+
the schema CAN say, and says.
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
const Currency = closed("Currency", ["Usd", "Eur", "Gbp"])
|
|
232
|
+
const Account = relation("Account", { id: u64.fresh, name: str })
|
|
233
|
+
const Posting = relation("Posting", {
|
|
234
|
+
id: u64.fresh,
|
|
235
|
+
account: u64,
|
|
236
|
+
currency: Currency.id,
|
|
237
|
+
minor: i64
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
const Money = schema("Money", { Currency, Account, Posting }, [
|
|
241
|
+
contained(on(Posting, "account"), on(Account, "id")),
|
|
242
|
+
contained(on(Posting, "currency"), on(Currency, "id"))
|
|
243
|
+
])
|
|
244
|
+
|
|
245
|
+
// Multi-currency totals: currency is a group key, never summed across —
|
|
246
|
+
// Sum folds wide with one final range check, so totals cannot wrap
|
|
247
|
+
// silently. Bind the fresh id: set semantics would collapse two equal
|
|
248
|
+
// (account, currency, minor) postings without it.
|
|
249
|
+
const totals = query(Money).rule((r) => {
|
|
250
|
+
const { id, account, currency, minor } = r.vars("id", "account", "currency", "minor")
|
|
251
|
+
return r.match(Posting, { id, account, currency, minor }).select("account", "currency", r.sum("minor"))
|
|
252
|
+
})
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
## 5. Content addressing
|
|
256
|
+
|
|
257
|
+
Guarantee: validator/runtime premises + host discipline — the payload key and
|
|
258
|
+
containments enforce identity/reference shape; hashing and blob durability stay external.
|
|
259
|
+
|
|
260
|
+
The decision rule for byte-shaped data: **intern what repeats (`str`); inline
|
|
261
|
+
what identifies (`bytes(n)`)**.
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
const Region = closed("Region", ["Us", "Eu"])
|
|
265
|
+
const Document = relation("Document", {
|
|
266
|
+
id: u64.fresh,
|
|
267
|
+
name: str, // repeats: interned, id-equality
|
|
268
|
+
payload: bytes(32) // identifies: the blake3 of the external blob — inline, never interned
|
|
269
|
+
})
|
|
270
|
+
const Replica = relation("Replica", { payload: bytes(32), region: Region.id })
|
|
271
|
+
|
|
272
|
+
const Content = schema("Content", { Region, Document, Replica }, [
|
|
273
|
+
key(Document, ["payload"]), // content-addressed: one doc per digest
|
|
274
|
+
// This containment is the digest columns' typing: neither is fresh, so
|
|
275
|
+
// the pair is a generator-less class named by its least member
|
|
276
|
+
// coordinate — "Document.payload" (the class laws' second rule).
|
|
277
|
+
contained(on(Replica, "payload"), on(Document, "payload")),
|
|
278
|
+
contained(on(Replica, "region"), on(Region, "id"))
|
|
279
|
+
// bytes(n) is identity-only (Eq/Ne, membership): a digest's lexicographic
|
|
280
|
+
// order is an encoding artifact, refused as semantics. Large objects:
|
|
281
|
+
// facts stay fixed-width; the payload lives in external storage,
|
|
282
|
+
// referenced by identity (the large-object refusal).
|
|
283
|
+
])
|
|
284
|
+
|
|
285
|
+
// a bytes param self-encodes (Uint8Array by inference):
|
|
286
|
+
const byDigest = query(Content).rule((r) => {
|
|
287
|
+
const { id } = r.vars("id")
|
|
288
|
+
return r.match(Document, { id, payload: r.param("digest") }).select("id")
|
|
289
|
+
})
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
## Vocabularies
|
|
293
|
+
|
|
294
|
+
## 6. The vocabulary
|
|
295
|
+
|
|
296
|
+
Guarantee: Lean theorem + validator/runtime premise — the sealed closed
|
|
297
|
+
extension is constant at every instance (`lean/Bumbledb/Schema.lean:
|
|
298
|
+
den_closed_constant`) and the compiled member-set containment admits only
|
|
299
|
+
declared priority handles.
|
|
300
|
+
|
|
301
|
+
The enum idiom's replacement, first-class: a vocabulary is a **closed
|
|
302
|
+
relation** — its ground axioms are declared in the schema, sealed at
|
|
303
|
+
validate, frozen by the fingerprint, virtual in storage. The store holds zero
|
|
304
|
+
vocabulary bytes, and handles are the literals on every surface.
|
|
305
|
+
|
|
306
|
+
```ts
|
|
307
|
+
// Tier 1: handles only. `closed()` mints one bare-bigint constant per handle
|
|
308
|
+
// (ids = declaration order) — an emission, not a type: the engine's
|
|
309
|
+
// vocabulary stays relational; the host matches on `Priority.Urgent`, or
|
|
310
|
+
// dispatches exhaustively with `Priority.match` (recipe 2).
|
|
311
|
+
const Priority = closed("Priority", ["Low", "Normal", "Urgent"])
|
|
312
|
+
|
|
313
|
+
const Ticket = relation("Ticket", { id: u64.fresh, priority: Priority.id, opened_at: i64 })
|
|
314
|
+
|
|
315
|
+
const Tickets = schema("Tickets", { Priority, Ticket }, [
|
|
316
|
+
// A closed reference is an ordinary u64 under one containment (which also
|
|
317
|
+
// types `priority` into the "Priority.id" generator class); the judgment
|
|
318
|
+
// compiles at validate to a member-set test — one AND, one bit test, no
|
|
319
|
+
// probe (30-dependencies.md).
|
|
320
|
+
contained(on(Ticket, "priority"), on(Priority, "id"))
|
|
321
|
+
])
|
|
322
|
+
|
|
323
|
+
// Handles are literals in queries exactly as in statements, and the
|
|
324
|
+
// renderer prints them back — the round trip runs on names. The boundary
|
|
325
|
+
// law: intrinsic meaning goes here (changing it is a new theory); policy
|
|
326
|
+
// that drifts without a rebuild is an ordinary relation — a vocabulary is
|
|
327
|
+
// never written, only declared.
|
|
328
|
+
const urgent = query(Tickets).rule((r) => {
|
|
329
|
+
const { t } = r.vars("t")
|
|
330
|
+
return r.match(Ticket, { id: t, priority: Priority.Urgent }).select("t")
|
|
331
|
+
})
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
## 7. The classification
|
|
335
|
+
|
|
336
|
+
Guarantee: validator/runtime premise — closed payload facts and the
|
|
337
|
+
ψ-selected containment restrict certificates to the compiled
|
|
338
|
+
mastered-handle set.
|
|
339
|
+
|
|
340
|
+
The fused form: the vocabulary carries its intrinsic facts as **payload
|
|
341
|
+
columns** — one ground axiom per handle, values sealed with the schema, read
|
|
342
|
+
by ψ-selections. Axioms are declared, never written.
|
|
343
|
+
|
|
344
|
+
```ts
|
|
345
|
+
// Tier 2: payload columns state what each word MEANS, next to the word.
|
|
346
|
+
// A rubric change is a new theory — exactly right for meaning.
|
|
347
|
+
const Kind = closed(
|
|
348
|
+
"Kind",
|
|
349
|
+
{ mastered: bool, rank: u64 },
|
|
350
|
+
{
|
|
351
|
+
DirectPass: { mastered: true, rank: 30n },
|
|
352
|
+
JudgedPass: { mastered: true, rank: 20n },
|
|
353
|
+
Failed: { mastered: false, rank: 10n }
|
|
354
|
+
}
|
|
355
|
+
)
|
|
356
|
+
const Attempt = relation("Attempt", { id: u64.fresh, kind: Kind.id })
|
|
357
|
+
const Certificate = relation("Certificate", { attempt: u64, kind: Kind.id })
|
|
358
|
+
|
|
359
|
+
const Review = schema("Review", { Kind, Attempt, Certificate }, [
|
|
360
|
+
contained(on(Attempt, "kind"), on(Kind, "id")),
|
|
361
|
+
key(Certificate, ["attempt"]),
|
|
362
|
+
contained(on(Certificate, "attempt"), on(Attempt, "id")),
|
|
363
|
+
// ψ reads the payload: certificates carry mastered kinds only — the
|
|
364
|
+
// member set {DirectPass, JudgedPass} compiles at validate and the
|
|
365
|
+
// judgment is O(1) at commit (recipe 8 is this statement's own recipe).
|
|
366
|
+
contained(on(Certificate, "kind"), on(Kind.where({ mastered: true }), "id"))
|
|
367
|
+
])
|
|
368
|
+
|
|
369
|
+
// The classification read duplicates no flag onto Attempt — ψ walks the
|
|
370
|
+
// vocabulary's payload in the query too: a closed relation is matchable
|
|
371
|
+
// exactly like an ordinary one, and the atom folds at prepare into a
|
|
372
|
+
// plan-constant handle set on its sibling.
|
|
373
|
+
const masteredAttempts = query(Review).rule((r) => {
|
|
374
|
+
const { a, k } = r.vars("a", "k")
|
|
375
|
+
return r
|
|
376
|
+
.match(Attempt, { id: a, kind: k })
|
|
377
|
+
.match(Kind, { id: k, mastered: true })
|
|
378
|
+
.select("a")
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
// Host dispatch on the payload tier hands each arm its sealed axiom row:
|
|
382
|
+
const label = (k: bigint) =>
|
|
383
|
+
Kind.match(k, {
|
|
384
|
+
DirectPass: (row) => `mastered, rank ${row.rank}`,
|
|
385
|
+
JudgedPass: (row) => `mastered, rank ${row.rank}`,
|
|
386
|
+
Failed: () => "not mastered"
|
|
387
|
+
})
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
Two honest boundaries. The fold has limits: payload escaping to the head and
|
|
391
|
+
param-bearing filters don't fold at prepare — the engine falls back to a
|
|
392
|
+
virtual-image join over the sealed extension, and the semantics are identical
|
|
393
|
+
either way. And for an ALREADY-DEPLOYED store, moving from the old
|
|
394
|
+
complement-window workaround to these ψ spellings is a NEW theory — a
|
|
395
|
+
different fingerprint — which is recipe 28's ETL territory: humans own that
|
|
396
|
+
migration.
|
|
397
|
+
|
|
398
|
+
## 8. The sub-vocabulary
|
|
399
|
+
|
|
400
|
+
Guarantee: validator/runtime premise — ψ over the sealed extension compiles
|
|
401
|
+
the exact paging member set; a nonmember write is commit-rejected.
|
|
402
|
+
|
|
403
|
+
The ψ-selected containment: a reference constrained to the facts of a
|
|
404
|
+
vocabulary that satisfy a payload selection. Because the target is closed and
|
|
405
|
+
sealed, the enforcement plan is not a probe strategy — it is **the answer set
|
|
406
|
+
itself**, fixed when the schema is built.
|
|
407
|
+
|
|
408
|
+
```ts
|
|
409
|
+
const Severity = closed(
|
|
410
|
+
"Severity",
|
|
411
|
+
{ pages: bool },
|
|
412
|
+
{
|
|
413
|
+
Info: { pages: false },
|
|
414
|
+
Warning: { pages: false },
|
|
415
|
+
Critical: { pages: true },
|
|
416
|
+
Fatal: { pages: true }
|
|
417
|
+
}
|
|
418
|
+
)
|
|
419
|
+
const Incident = relation("Incident", { id: u64.fresh, severity: Severity.id })
|
|
420
|
+
const Escalation = relation("Escalation", { incident: u64, severity: Severity.id, at: i64 })
|
|
421
|
+
|
|
422
|
+
const Oncall = schema("Oncall", { Severity, Incident, Escalation }, [
|
|
423
|
+
contained(on(Incident, "severity"), on(Severity, "id")),
|
|
424
|
+
contained(on(Escalation, "incident"), on(Incident, "id")),
|
|
425
|
+
// The sub-vocabulary: an escalation carries a PAGING severity, by
|
|
426
|
+
// statement. ψ over the sealed extension compiles to the member set
|
|
427
|
+
// {Critical, Fatal}; the judgment is one bit test per touched fact,
|
|
428
|
+
// and an escalation at Severity.Info aborts the commit.
|
|
429
|
+
contained(on(Escalation, "severity"), on(Severity.where({ pages: true }), "id"))
|
|
430
|
+
])
|
|
431
|
+
|
|
432
|
+
// who is being paged — the same ψ, on the read side:
|
|
433
|
+
const paged = query(Oncall).rule((r) => {
|
|
434
|
+
const { i, s } = r.vars("i", "s")
|
|
435
|
+
return r
|
|
436
|
+
.match(Escalation, { incident: i, severity: s })
|
|
437
|
+
.match(Severity, { id: s, pages: true })
|
|
438
|
+
.select("i")
|
|
439
|
+
})
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
## Structure
|
|
443
|
+
|
|
444
|
+
## 9. Ordered collections
|
|
445
|
+
|
|
446
|
+
Guarantee: Lean theorem + validator/runtime premises — mutual point coverage
|
|
447
|
+
plus pointwise keys realizes exact partition
|
|
448
|
+
(`lean/Bumbledb/Dependencies.lean: exact_partition_iff`), and the mixed-width
|
|
449
|
+
interval positions type by element domain
|
|
450
|
+
(`lean/Bumbledb/Schema.lean: Value.points_one_tag_u64`); ordering the result
|
|
451
|
+
remains a host presentation step.
|
|
452
|
+
|
|
453
|
+
The linked-list verdict: successor pointers are control flow smuggled into
|
|
454
|
+
data. Order is a value. The idiomatic ordered collection is an interval
|
|
455
|
+
partition, spelled as a **triple**: the entity, the extent as a 0..1 child
|
|
456
|
+
(empty lists exist, empty intervals do not — presence of the child IS
|
|
457
|
+
nonemptiness), and the unit-slot sidecar (`interval(u64, 1n)` — the width is
|
|
458
|
+
the type: a wrong-width value is unrepresentable).
|
|
459
|
+
|
|
460
|
+
```ts
|
|
461
|
+
const Playlist = relation("Playlist", { id: u64.fresh, name: str })
|
|
462
|
+
// The extent: a 0..1 child, because empty playlists exist and empty
|
|
463
|
+
// intervals do not — presence of the child IS nonemptiness.
|
|
464
|
+
const Extent = relation("Extent", { playlist: u64, span: interval(u64) })
|
|
465
|
+
// The unit slot: position p occupies [p, p+1) — the width is the type.
|
|
466
|
+
const Slot = relation("Slot", { playlist: u64, slot: interval(u64, 1n), track: str })
|
|
467
|
+
|
|
468
|
+
const Playlists = schema("Playlists", { Playlist, Extent, Slot }, [
|
|
469
|
+
contained(on(Extent, "playlist"), on(Playlist, "id")),
|
|
470
|
+
contained(on(Slot, "playlist"), on(Playlist, "id")),
|
|
471
|
+
key(Extent, ["playlist"]), // 0..1 extent per playlist
|
|
472
|
+
key(Extent, ["playlist", "span"]), // exact target key (recipe 26's note)
|
|
473
|
+
key(Slot, ["playlist", "slot"]), // one occupant per position
|
|
474
|
+
mirrors(on(Extent, ["playlist", "span"]), on(Slot, ["playlist", "slot"])) // slots tile the span exactly
|
|
475
|
+
])
|
|
476
|
+
|
|
477
|
+
// Positional access is membership — "what plays at position ?pos":
|
|
478
|
+
const playingAt = query(Playlists).rule((r) => {
|
|
479
|
+
const { slot, track } = r.vars("slot", "track")
|
|
480
|
+
return r
|
|
481
|
+
.match(Slot, { playlist: r.param("list"), slot, track })
|
|
482
|
+
.where(pointIn(r.param("pos"), slot))
|
|
483
|
+
.select("track")
|
|
484
|
+
})
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
Middle insert is honest about its cost: making room at position `k` shifts
|
|
488
|
+
every later slot and grows the extent — O(k) writes in **one delta**, judged
|
|
489
|
+
once at commit. If middle inserts dominate, the demoted escape hatch is the
|
|
490
|
+
spread slot: a scalar `pos: u64` written in gapped strides under the same
|
|
491
|
+
composite key — bumbledb has no lexicographic fractional indexing, because
|
|
492
|
+
string order is refused: there is no "between two strings" to allocate.
|
|
493
|
+
|
|
494
|
+
## 10. Trees and ASTs
|
|
495
|
+
|
|
496
|
+
Guarantee: Lean theorem + validator/runtime premises for key-backed arms
|
|
497
|
+
(`lean/Bumbledb/Dependencies.lean: keyed_eq_unique_correspondence`); host
|
|
498
|
+
discipline for acyclicity — statements prove arm/edge shape, never a tree theorem.
|
|
499
|
+
|
|
500
|
+
Node header + per-kind arms (recipe 2's pattern); every edge resolves; the
|
|
501
|
+
shape theorems come from keys on the edge relations.
|
|
502
|
+
|
|
503
|
+
```ts
|
|
504
|
+
const Kind = closed("Kind", ["Lit", "Add"])
|
|
505
|
+
const Node = relation("Node", { id: u64.fresh, kind: Kind.id })
|
|
506
|
+
const Lit = relation("Lit", { node: u64, value: i64 })
|
|
507
|
+
const Add = relation("Add", { node: u64, lhs: u64, rhs: u64 })
|
|
508
|
+
const Parent = relation("Parent", { child: u64, parent: u64 })
|
|
509
|
+
|
|
510
|
+
const Ast = schema("Ast", { Kind, Node, Lit, Add, Parent }, [
|
|
511
|
+
contained(on(Node, "kind"), on(Kind, "id")),
|
|
512
|
+
key(Lit, ["node"]),
|
|
513
|
+
key(Add, ["node"]),
|
|
514
|
+
// Every node's arm is total, valid, and exclusive (recipe 2's theorems):
|
|
515
|
+
mirrors(on(Node.where({ kind: Kind.Lit }), "id"), on(Lit, "node")),
|
|
516
|
+
mirrors(on(Node.where({ kind: Kind.Add }), "id"), on(Add, "node")),
|
|
517
|
+
// Every child edge resolves — no dangling subtrees, judged at commit
|
|
518
|
+
// (these containments also put lhs/rhs in the "Node.id" class, which is
|
|
519
|
+
// exactly what lets the query below join lhs against Lit.node):
|
|
520
|
+
contained(on(Add, "lhs"), on(Node, "id")),
|
|
521
|
+
contained(on(Add, "rhs"), on(Node, "id")),
|
|
522
|
+
// Functional parent (one parent per child) ⇒ the reachable shape is
|
|
523
|
+
// paths-or-cycles; acyclicity itself is outside the ∀∃ vocabulary —
|
|
524
|
+
// host discipline, recorded. Transitive reach is recipe 24's closure.
|
|
525
|
+
key(Parent, ["child"]),
|
|
526
|
+
contained(on(Parent, "child"), on(Node, "id")),
|
|
527
|
+
contained(on(Parent, "parent"), on(Node, "id"))
|
|
528
|
+
])
|
|
529
|
+
|
|
530
|
+
const lhsLiteral = query(Ast).rule((r) => {
|
|
531
|
+
const { l, v } = r.vars("l", "v")
|
|
532
|
+
return r
|
|
533
|
+
.match(Add, { node: r.param("n"), lhs: l })
|
|
534
|
+
.match(Lit, { node: l, value: v })
|
|
535
|
+
.select("v")
|
|
536
|
+
})
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
## 11. Typed graphs
|
|
540
|
+
|
|
541
|
+
Guarantee: validator/runtime premises — endpoint containments type each edge
|
|
542
|
+
and composite keys deduplicate pairs; no transitive graph property is claimed.
|
|
543
|
+
|
|
544
|
+
One relation per edge kind: endpoint containments pin which node kinds each
|
|
545
|
+
edge may touch.
|
|
546
|
+
|
|
547
|
+
```ts
|
|
548
|
+
const Person = relation("Person", { id: u64.fresh, name: str })
|
|
549
|
+
const Repo = relation("Repo", { id: u64.fresh, name: str })
|
|
550
|
+
const Follows = relation("Follows", { follower: u64, followee: u64 })
|
|
551
|
+
const Maintains = relation("Maintains", { person: u64, repo: u64 })
|
|
552
|
+
|
|
553
|
+
const Graph = schema("Graph", { Person, Repo, Follows, Maintains }, [
|
|
554
|
+
contained(on(Follows, "follower"), on(Person, "id")), // a Person→Person edge, by statement —
|
|
555
|
+
contained(on(Follows, "followee"), on(Person, "id")), // a Follows fact cannot touch a Repo
|
|
556
|
+
key(Follows, ["follower", "followee"]), // at most one edge per pair
|
|
557
|
+
contained(on(Maintains, "person"), on(Person, "id")),
|
|
558
|
+
contained(on(Maintains, "repo"), on(Repo, "id")),
|
|
559
|
+
key(Maintains, ["person", "repo"])
|
|
560
|
+
])
|
|
561
|
+
|
|
562
|
+
// Mutual follows — joins are explicit var reuse on both ends (both columns
|
|
563
|
+
// live in the "Person.id" class, so the reuse is lawful); `lt` keeps each
|
|
564
|
+
// pair once:
|
|
565
|
+
const mutual = query(Graph).rule((r) => {
|
|
566
|
+
const { a, b } = r.vars("a", "b")
|
|
567
|
+
return r
|
|
568
|
+
.match(Follows, { follower: a, followee: b })
|
|
569
|
+
.match(Follows, { follower: b, followee: a })
|
|
570
|
+
.where(lt(a, b))
|
|
571
|
+
.select("a", "b")
|
|
572
|
+
})
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
## 12. Entity-component
|
|
576
|
+
|
|
577
|
+
Guarantee: definition + validator/runtime premises — component keys give 0..1
|
|
578
|
+
and containments require the stated entity/archetype facts.
|
|
579
|
+
|
|
580
|
+
The 0..1 idiom (recipe 3) at scale: components are sidecar relations; an
|
|
581
|
+
entity has a component iff the fact exists; a new component kind is a new
|
|
582
|
+
relation, not a wider fact.
|
|
583
|
+
|
|
584
|
+
```ts
|
|
585
|
+
const Entity = relation("Entity", { id: u64.fresh, name: str })
|
|
586
|
+
const Transform = relation("Transform", { entity: u64, x: i64, y: i64 })
|
|
587
|
+
const Velocity = relation("Velocity", { entity: u64, dx: i64, dy: i64 })
|
|
588
|
+
const Renderable = relation("Renderable", { entity: u64, mesh: str })
|
|
589
|
+
|
|
590
|
+
const Ecs = schema("Ecs", { Entity, Transform, Velocity, Renderable }, [
|
|
591
|
+
key(Transform, ["entity"]), // each component 0..1 per entity
|
|
592
|
+
contained(on(Transform, "entity"), on(Entity, "id")),
|
|
593
|
+
key(Velocity, ["entity"]),
|
|
594
|
+
contained(on(Velocity, "entity"), on(Entity, "id")),
|
|
595
|
+
key(Renderable, ["entity"]),
|
|
596
|
+
// An archetype rule is one containment: every Renderable has a Transform
|
|
597
|
+
// (and, through it, an Entity — containment composes, and the class
|
|
598
|
+
// composes with it: every `entity` column lands in "Entity.id").
|
|
599
|
+
contained(on(Renderable, "entity"), on(Transform, "entity"))
|
|
600
|
+
])
|
|
601
|
+
|
|
602
|
+
// The physics join is the component intersection:
|
|
603
|
+
const physics = query(Ecs).rule((r) => {
|
|
604
|
+
const { entity, x, y, dx, dy } = r.vars("entity", "x", "y", "dx", "dy")
|
|
605
|
+
return r
|
|
606
|
+
.match(Transform, { entity, x, y })
|
|
607
|
+
.match(Velocity, { entity, dx, dy })
|
|
608
|
+
.select("entity", "x", "y", "dx", "dy")
|
|
609
|
+
})
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
## 13. State machines
|
|
613
|
+
|
|
614
|
+
Guarantee: Lean theorem + validator/runtime premises for the shipped arm
|
|
615
|
+
(`lean/Bumbledb/Dependencies.lean: keyed_eq_unique_correspondence`); host
|
|
616
|
+
discipline for allowed transitions — equality pins state evidence, not paths.
|
|
617
|
+
|
|
618
|
+
States are a discriminated union; per-state data lives in arms; and the
|
|
619
|
+
conditional reference target — a reference to "an order *that is shipped*" —
|
|
620
|
+
is one selected statement, the statement SQL cannot write.
|
|
621
|
+
|
|
622
|
+
```ts
|
|
623
|
+
const State = closed("State", ["Cart", "Placed", "Shipped"])
|
|
624
|
+
const Order = relation("Order", { id: u64.fresh, state: State.id })
|
|
625
|
+
const Placement = relation("Placement", { order: u64, at: i64 })
|
|
626
|
+
const Shipment = relation("Shipment", { order: u64, carrier: str, at: i64 })
|
|
627
|
+
|
|
628
|
+
const Orders = schema("Orders", { State, Order, Placement, Shipment }, [
|
|
629
|
+
contained(on(Order, "state"), on(State, "id")),
|
|
630
|
+
key(Placement, ["order"]),
|
|
631
|
+
key(Shipment, ["order"]),
|
|
632
|
+
// History accretes: a Shipped order keeps its Placement — one-way
|
|
633
|
+
// containment admits arms from earlier states surviving the transition.
|
|
634
|
+
contained(on(Placement, "order"), on(Order, "id")),
|
|
635
|
+
// The conditional target, both ways: every Shipment references an order
|
|
636
|
+
// THAT IS Shipped (validity), and every Shipped order has its Shipment
|
|
637
|
+
// (totality) — the transition and its evidence commit together.
|
|
638
|
+
mirrors(on(Shipment, "order"), on(Order.where({ state: State.Shipped }), "id"))
|
|
639
|
+
// Transition predicates ("only Placed may ship") are host code under the
|
|
640
|
+
// generation witness — recipe 20; the schema pins the states, not the paths.
|
|
641
|
+
])
|
|
642
|
+
|
|
643
|
+
const shipped = query(Orders).rule((r) => {
|
|
644
|
+
const { id, carrier } = r.vars("id", "carrier")
|
|
645
|
+
return r
|
|
646
|
+
.match(Order, { id, state: State.Shipped })
|
|
647
|
+
.match(Shipment, { order: id, carrier })
|
|
648
|
+
.select("id", "carrier")
|
|
649
|
+
})
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
## Time and coverage
|
|
653
|
+
|
|
654
|
+
## 14. The calendar core
|
|
655
|
+
|
|
656
|
+
Guarantee: Lean theorem + validator/runtime premises — accepted equality is
|
|
657
|
+
key-backed correspondence (`lean/Bumbledb/Dependencies.lean:
|
|
658
|
+
keyed_eq_unique_correspondence`), while pointwise keys/coverage enforce only
|
|
659
|
+
declared hard policy.
|
|
660
|
+
|
|
661
|
+
Policy as schema: hard rules are pointwise keys, soft rules are the statements
|
|
662
|
+
you decline to write. `Claim.source` carries the accepted attendance's id and
|
|
663
|
+
declares nothing about it — the selected `mirrors` law below is what TYPES
|
|
664
|
+
it: pairing `source` with `Attendance.id` puts it in the `"Attendance.id"`
|
|
665
|
+
generator class. The class flows from the statement; no link declaration
|
|
666
|
+
exists or is needed.
|
|
667
|
+
|
|
668
|
+
```ts
|
|
669
|
+
const Rsvp = closed("Rsvp", ["Accepted", "Tentative", "Declined"])
|
|
670
|
+
const Arm = closed("Arm", ["Busy", "Ooo"])
|
|
671
|
+
|
|
672
|
+
const Person = relation("Person", { id: u64.fresh, name: str })
|
|
673
|
+
const Room = relation("Room", { id: u64.fresh, name: str })
|
|
674
|
+
const Event = relation("Event", { id: u64.fresh, span: interval(i64) })
|
|
675
|
+
const Attendance = relation("Attendance", {
|
|
676
|
+
id: u64.fresh,
|
|
677
|
+
event: u64,
|
|
678
|
+
person: u64,
|
|
679
|
+
rsvp: Rsvp.id
|
|
680
|
+
})
|
|
681
|
+
const Claim = relation("Claim", {
|
|
682
|
+
source: u64,
|
|
683
|
+
person: u64,
|
|
684
|
+
arm: Arm.id,
|
|
685
|
+
span: interval(i64)
|
|
686
|
+
})
|
|
687
|
+
const Booking = relation("Booking", { room: u64, event: u64, span: interval(i64) })
|
|
688
|
+
const WorkHours = relation("WorkHours", { person: u64, hours: interval(i64) })
|
|
689
|
+
|
|
690
|
+
const Calendar = schema("Calendar", { Rsvp, Arm, Person, Room, Event, Attendance, Claim, Booking, WorkHours }, [
|
|
691
|
+
contained(on(Attendance, "event"), on(Event, "id")),
|
|
692
|
+
contained(on(Attendance, "person"), on(Person, "id")),
|
|
693
|
+
contained(on(Attendance, "rsvp"), on(Rsvp, "id")),
|
|
694
|
+
key(Attendance, ["event", "person"]), // one RSVP per (event, person)
|
|
695
|
+
key(Claim, ["source"]),
|
|
696
|
+
contained(on(Claim, "person"), on(Person, "id")),
|
|
697
|
+
contained(on(Claim, "arm"), on(Arm, "id")),
|
|
698
|
+
// HARD: rooms cannot double-book — the pointwise key (recipe 1's theorem).
|
|
699
|
+
key(Booking, ["room", "span"]),
|
|
700
|
+
// SOFT: people CAN double-book — key(Claim, ["person", "span"]) is simply
|
|
701
|
+
// not declared. Policy is the presence or absence of one statement.
|
|
702
|
+
// Accepting an invitation IS claiming the time (totality + validity) —
|
|
703
|
+
// and this is the statement that types Claim.source:
|
|
704
|
+
mirrors(on(Attendance.where({ rsvp: Rsvp.Accepted }), "id"), on(Claim.where({ arm: Arm.Busy }), "source")),
|
|
705
|
+
// Busy time lies inside working hours, pointwise — coverage rides the
|
|
706
|
+
// target's own key (disjoint + ordered is a theorem, not a request):
|
|
707
|
+
key(WorkHours, ["person", "hours"]),
|
|
708
|
+
contained(on(Claim.where({ arm: Arm.Busy }), ["person", "span"]), on(WorkHours, ["person", "hours"])),
|
|
709
|
+
contained(on(Booking, "room"), on(Room, "id")),
|
|
710
|
+
contained(on(Booking, "event"), on(Event, "id"))
|
|
711
|
+
])
|
|
712
|
+
|
|
713
|
+
const roomConflicts = query(Calendar).rule((r) => {
|
|
714
|
+
const { room, span } = r.vars("room", "span")
|
|
715
|
+
return r
|
|
716
|
+
.match(Booking, { room, span })
|
|
717
|
+
.where(allen(span, ALLEN.intersects, r.param("want")))
|
|
718
|
+
.select("room", "span")
|
|
719
|
+
})
|
|
720
|
+
const personLoad = query(Calendar).rule((r) => {
|
|
721
|
+
const { person, span } = r.vars("person", "span")
|
|
722
|
+
return r
|
|
723
|
+
.match(Claim, { person, span })
|
|
724
|
+
.where(allen(span, ALLEN.intersects, r.param("window")))
|
|
725
|
+
.select("person", "span")
|
|
726
|
+
})
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
## 15. Effective-dated configuration
|
|
730
|
+
|
|
731
|
+
Guarantee: Lean theorem/countermodel + validator/runtime premise — pointwise
|
|
732
|
+
keys plus one-way support inclusion form a disjoint cover
|
|
733
|
+
(`lean/Bumbledb/Dependencies.lean: pointwise_key_disjoint`,
|
|
734
|
+
`coverage_is_support_inclusion`); target overhang is legal
|
|
735
|
+
(`lean/Bumbledb/Countermodels.lean: one_way_overhang`).
|
|
736
|
+
|
|
737
|
+
Versioned rules: no overlaps (pointwise key), no gaps in the policy's source
|
|
738
|
+
lifetime (one-way coverage; version overhang remains legal), and "in force on
|
|
739
|
+
date t" is one membership probe.
|
|
740
|
+
|
|
741
|
+
```ts
|
|
742
|
+
const Policy = relation("Policy", { id: u64.fresh, live: interval(i64) })
|
|
743
|
+
const Version = relation("Version", { policy: u64, rate_bps: i64, valid: interval(i64) })
|
|
744
|
+
|
|
745
|
+
const Pricing = schema("Pricing", { Policy, Version }, [
|
|
746
|
+
contained(on(Version, "policy"), on(Policy, "id")),
|
|
747
|
+
// No overlapping versions: at any instant, at most one rate is the law.
|
|
748
|
+
key(Version, ["policy", "valid"]),
|
|
749
|
+
// No gaps in the policy lifetime: every source point is covered by
|
|
750
|
+
// versions. Together with the key above this is a disjoint cover, not an
|
|
751
|
+
// exact partition: Version intervals may overhang (recipe 16).
|
|
752
|
+
contained(on(Policy, ["id", "live"]), on(Version, ["policy", "valid"]))
|
|
753
|
+
])
|
|
754
|
+
|
|
755
|
+
// in force on date t — one membership probe:
|
|
756
|
+
const inForce = query(Pricing).rule((r) => {
|
|
757
|
+
const { rate_bps, valid } = r.vars("rate_bps", "valid")
|
|
758
|
+
return r
|
|
759
|
+
.match(Version, { policy: r.param("p"), rate_bps, valid })
|
|
760
|
+
.where(pointIn(r.param("t"), valid))
|
|
761
|
+
.select("rate_bps")
|
|
762
|
+
})
|
|
763
|
+
// clean successions (half-open makes MEETS exact, no ±1 fudge):
|
|
764
|
+
const successions = query(Pricing).rule((r) => {
|
|
765
|
+
const { p, a, b } = r.vars("p", "a", "b")
|
|
766
|
+
return r
|
|
767
|
+
.match(Version, { policy: p, valid: a })
|
|
768
|
+
.match(Version, { policy: p, valid: b })
|
|
769
|
+
.where(allen(a, ALLEN.meets, b))
|
|
770
|
+
.select("a", "b")
|
|
771
|
+
})
|
|
772
|
+
```
|
|
773
|
+
|
|
774
|
+
## 16. Disjoint covers
|
|
775
|
+
|
|
776
|
+
Guarantee: Lean theorem/countermodel + validator/runtime premise —
|
|
777
|
+
`lean/Bumbledb/Dependencies.lean: coverage_is_support_inclusion` proves source
|
|
778
|
+
coverage, not exact partition (`lean/Bumbledb/Countermodels.lean:
|
|
779
|
+
one_way_overhang`).
|
|
780
|
+
|
|
781
|
+
Pay periods, shifts, estimated-tax quarters: a pointwise key plus one-way
|
|
782
|
+
coverage is a **disjoint cover** — no overlaps among pay periods and no holes
|
|
783
|
+
in the fiscal year's source span. Pay periods may extend beyond that span.
|
|
784
|
+
|
|
785
|
+
```ts
|
|
786
|
+
const FiscalYear = relation("FiscalYear", { id: u64.fresh, span: interval(i64) })
|
|
787
|
+
const PayPeriod = relation("PayPeriod", { year: u64, seq: u64, span: interval(i64) })
|
|
788
|
+
|
|
789
|
+
const Payroll = schema("Payroll", { FiscalYear, PayPeriod }, [
|
|
790
|
+
contained(on(PayPeriod, "year"), on(FiscalYear, "id")),
|
|
791
|
+
key(PayPeriod, ["year", "seq"]), // sequence numbers stay unique
|
|
792
|
+
key(PayPeriod, ["year", "span"]), // disjoint: no shared instant
|
|
793
|
+
// Covering: no holes in the fiscal year's span; overhang is legal.
|
|
794
|
+
contained(on(FiscalYear, ["id", "span"]), on(PayPeriod, ["year", "span"]))
|
|
795
|
+
])
|
|
796
|
+
|
|
797
|
+
// the period holding date t:
|
|
798
|
+
const holding = query(Payroll).rule((r) => {
|
|
799
|
+
const { seq, span } = r.vars("seq", "span")
|
|
800
|
+
return r
|
|
801
|
+
.match(PayPeriod, { year: r.param("y"), seq, span })
|
|
802
|
+
.where(pointIn(r.param("t"), span))
|
|
803
|
+
.select("seq")
|
|
804
|
+
})
|
|
805
|
+
```
|
|
806
|
+
|
|
807
|
+
## 17. Federal income tax
|
|
808
|
+
|
|
809
|
+
Guarantee: validator/runtime premises + host discipline — keys prove bracket
|
|
810
|
+
disjointness and statements prove residency coverage; full bracket coverage and proration are host duties.
|
|
811
|
+
|
|
812
|
+
Brackets are intervals over money; the top bracket is a ray; regimes key on
|
|
813
|
+
(year, status); and proration happens at write time, never at query time.
|
|
814
|
+
|
|
815
|
+
```ts
|
|
816
|
+
const Status = closed("Status", ["Single", "MarriedJoint", "HeadOfHousehold"])
|
|
817
|
+
const Regime = relation("Regime", { id: u64.fresh, year: i64, status: Status.id })
|
|
818
|
+
const Bracket = relation("Bracket", { regime: u64, income: interval(i64), rate_bps: i64 })
|
|
819
|
+
const Residency = relation("Residency", { person: u64, span: interval(i64) })
|
|
820
|
+
// Split at write: an Earned fact never spans a year boundary — writers
|
|
821
|
+
// split (prorate) at the boundary, so no reader ever clips. The
|
|
822
|
+
// representation move that deletes clip-at-query (gravestone, recipe 23).
|
|
823
|
+
const Earned = relation("Earned", { person: u64, regime: u64, span: interval(i64), minor: i64 })
|
|
824
|
+
|
|
825
|
+
const Tax = schema("Tax", { Status, Regime, Bracket, Residency, Earned }, [
|
|
826
|
+
contained(on(Regime, "status"), on(Status, "id")),
|
|
827
|
+
key(Regime, ["year", "status"]), // one regime per (year, filing status)
|
|
828
|
+
contained(on(Bracket, "regime"), on(Regime, "id")),
|
|
829
|
+
// Brackets are disjoint per regime. Seed data conventionally covers
|
|
830
|
+
// [0, ∞) and the top bracket is a ray, but this key proves disjointness
|
|
831
|
+
// only. end == MAX denotes [s, ∞), an honest value of the representation,
|
|
832
|
+
// not a sentinel (the point-domain law).
|
|
833
|
+
key(Bracket, ["regime", "income"]),
|
|
834
|
+
contained(on(Earned, "regime"), on(Regime, "id")),
|
|
835
|
+
key(Residency, ["person", "span"]),
|
|
836
|
+
// Residency exclusion: income counts only where earned inside a residency
|
|
837
|
+
// period — pointwise coverage, the same judgment as recipe 15's. This
|
|
838
|
+
// pair statement is also what puts the two bare `person` columns in one
|
|
839
|
+
// (generator-less) class: "Residency.person", by least coordinate.
|
|
840
|
+
contained(on(Earned, ["person", "span"]), on(Residency, ["person", "span"]))
|
|
841
|
+
])
|
|
842
|
+
|
|
843
|
+
// the marginal bracket (membership probes the disjoint bracket set). Tax
|
|
844
|
+
// owed is host arithmetic over the bracket walk — arithmetic beyond the
|
|
845
|
+
// measure is refused (the ledger).
|
|
846
|
+
const marginal = query(Tax).rule((r) => {
|
|
847
|
+
const { reg, b, rate_bps } = r.vars("reg", "b", "rate_bps")
|
|
848
|
+
return r
|
|
849
|
+
.match(Regime, { id: reg, year: r.param("y"), status: r.param("s") })
|
|
850
|
+
.match(Bracket, { regime: reg, income: b, rate_bps })
|
|
851
|
+
.where(pointIn(r.param("taxable"), b))
|
|
852
|
+
.select("rate_bps")
|
|
853
|
+
})
|
|
854
|
+
```
|
|
855
|
+
|
|
856
|
+
## 18. Free time and coalescing
|
|
857
|
+
|
|
858
|
+
Guarantee: Lean theorem + runtime query semantics — `pack` coalesces answer
|
|
859
|
+
intervals (`lean/Bumbledb/Query/Aggregates.lean: pack_canonical`,
|
|
860
|
+
`pack_extensional`); it asserts no stored disjointness, completeness, or
|
|
861
|
+
maintenance behavior.
|
|
862
|
+
|
|
863
|
+
`pack` is Snodgrass's coalesce as an aggregate — maximal disjoint segments per
|
|
864
|
+
group, one answer per (group, segment). Coalescing is never a write rule: the
|
|
865
|
+
engine stores the claims it was given.
|
|
866
|
+
|
|
867
|
+
```ts
|
|
868
|
+
const Person = relation("Person", { id: u64.fresh, name: str })
|
|
869
|
+
const Claim = relation("Claim", { person: u64, span: interval(i64) })
|
|
870
|
+
|
|
871
|
+
const FreeTime = schema("FreeTime", { Person, Claim }, [
|
|
872
|
+
contained(on(Claim, "person"), on(Person, "id"))
|
|
873
|
+
// No pointwise key, on purpose: claims overlap freely and pack coalesces
|
|
874
|
+
// at read time. Wanting them stored-disjoint is recipe 1's key instead.
|
|
875
|
+
])
|
|
876
|
+
|
|
877
|
+
// busy time, coalesced (adjacent segments merge — the half-open law):
|
|
878
|
+
const busy = query(FreeTime).rule((r) => {
|
|
879
|
+
const { person, span } = r.vars("person", "span")
|
|
880
|
+
return r.match(Claim, { person, span }).select("person", r.pack("span"))
|
|
881
|
+
})
|
|
882
|
+
// raw claimed time (overlaps double-count — often the wrong question):
|
|
883
|
+
const claimed = query(FreeTime).rule((r) => {
|
|
884
|
+
const { person, span } = r.vars("person", "span")
|
|
885
|
+
return r.match(Claim, { person, span }).select("person", r.sum(r.duration("span")))
|
|
886
|
+
})
|
|
887
|
+
// Coalesced totals = the two-query composition (pack, then a host fold) —
|
|
888
|
+
// aggregates never nest; free time (gaps) is the two-line host walk over
|
|
889
|
+
// sorted packed answers — both refusals recorded in the ledger.
|
|
890
|
+
```
|
|
891
|
+
|
|
892
|
+
## The write side
|
|
893
|
+
|
|
894
|
+
## 19. The ledger
|
|
895
|
+
|
|
896
|
+
Guarantee: Lean theorem + runtime invariant for bounded sums
|
|
897
|
+
(`lean/Bumbledb/Query/Aggregates.lean: checkedSum_sound`); host discipline
|
|
898
|
+
for double entry — statements resolve posting references, not arithmetic agreement.
|
|
899
|
+
|
|
900
|
+
The census workload. Balance is a query, never a column.
|
|
901
|
+
|
|
902
|
+
```ts
|
|
903
|
+
const Account = relation("Account", { id: u64.fresh, name: str })
|
|
904
|
+
const JournalEntry = relation("JournalEntry", { id: u64.fresh, at: i64, memo: str })
|
|
905
|
+
const Posting = relation("Posting", {
|
|
906
|
+
id: u64.fresh,
|
|
907
|
+
entry: u64,
|
|
908
|
+
account: u64,
|
|
909
|
+
minor: i64
|
|
910
|
+
})
|
|
911
|
+
|
|
912
|
+
const Ledger = schema("Ledger", { Account, JournalEntry, Posting }, [
|
|
913
|
+
contained(on(Posting, "entry"), on(JournalEntry, "id")),
|
|
914
|
+
contained(on(Posting, "account"), on(Account, "id"))
|
|
915
|
+
// A stored balance column equaling Sum(postings) is the arithmetic-
|
|
916
|
+
// agreement statement — refused (the ledger): statements prove presence
|
|
917
|
+
// and topology, never that a value equals a computation. Balance is host
|
|
918
|
+
// arithmetic over sum; a materialized rollup is recipe 21's shape.
|
|
919
|
+
])
|
|
920
|
+
|
|
921
|
+
// balances (bind the fresh id — set semantics collapses duplicates):
|
|
922
|
+
const balances = query(Ledger).rule((r) => {
|
|
923
|
+
const { id, account, minor } = r.vars("id", "account", "minor")
|
|
924
|
+
return r.match(Posting, { id, account, minor }).select("account", r.sum("minor"))
|
|
925
|
+
})
|
|
926
|
+
// double-entry audit (host asserts every total is 0 — discipline, not schema):
|
|
927
|
+
const doubleEntry = query(Ledger).rule((r) => {
|
|
928
|
+
const { id, entry, minor } = r.vars("id", "entry", "minor")
|
|
929
|
+
return r.match(Posting, { id, entry, minor }).select("entry", r.sum("minor"))
|
|
930
|
+
})
|
|
931
|
+
```
|
|
932
|
+
|
|
933
|
+
## 20. Conditional writes
|
|
934
|
+
|
|
935
|
+
Guarantee: Lean theorem + generation-witness/runtime premise + host retry
|
|
936
|
+
discipline — snapshot-derived writes detect movement
|
|
937
|
+
(`lean/Bumbledb/Txn.lean: writeFrom_moved`, `witness_conflict_distinct`);
|
|
938
|
+
final-state point reads need no earlier witness.
|
|
939
|
+
|
|
940
|
+
The generation witness: read the model, propose a delta, commit iff the model
|
|
941
|
+
you read is still the model. In the SDK the whole loop is `db.writeWitnessed`
|
|
942
|
+
— retry on movement is built in (every generation move is self-inflicted by
|
|
943
|
+
the host's own interleaved writes), and `abandon(payload)` declines to commit
|
|
944
|
+
without issuing anything.
|
|
945
|
+
|
|
946
|
+
```ts
|
|
947
|
+
const State = closed("State", ["Queued", "Running", "Done"])
|
|
948
|
+
const Job = relation("Job", { id: u64.fresh, state: State.id, payload: str })
|
|
949
|
+
const Lease = relation("Lease", { job: u64, worker: u64, until: i64 })
|
|
950
|
+
|
|
951
|
+
const Jobs = schema("Jobs", { State, Job, Lease }, [
|
|
952
|
+
contained(on(Job, "state"), on(State, "id")),
|
|
953
|
+
key(Lease, ["job"]),
|
|
954
|
+
// A lease exists iff its job is Running (recipe 13's conditional target):
|
|
955
|
+
// claiming a job and leasing it commit together or not at all.
|
|
956
|
+
mirrors(on(Lease, "job"), on(Job.where({ state: State.Running }), "id"))
|
|
957
|
+
])
|
|
958
|
+
|
|
959
|
+
// update-where's premise — "still Queued" is the witness:
|
|
960
|
+
const stillQueued = query(Jobs).rule((r) => {
|
|
961
|
+
const { id, payload } = r.vars("id", "payload")
|
|
962
|
+
return r.match(Job, { id, state: State.Queued, payload }).select("id", "payload")
|
|
963
|
+
})
|
|
964
|
+
|
|
965
|
+
const db = await Db.create("./jobs.db", Jobs)
|
|
966
|
+
const prepared = db.prepare(stillQueued)
|
|
967
|
+
|
|
968
|
+
// The witnessed loop: premise reads via `snap`, the delta via `tx`; on a
|
|
969
|
+
// moved generation the WHOLE callback reruns on a fresh snapshot. The other
|
|
970
|
+
// two idioms: insert-select is the same shape (query source answers, insert
|
|
971
|
+
// the derived facts); key-shaped read-modify-write uses `tx.get`/`tx.contains`
|
|
972
|
+
// — final-state point reads need no earlier witness.
|
|
973
|
+
const outcome = db.writeWitnessed(function updateWhere(snap, tx) {
|
|
974
|
+
const queued = snap.execute(prepared, {})
|
|
975
|
+
if (queued.length === 0) {
|
|
976
|
+
return abandon("nothing queued")
|
|
977
|
+
}
|
|
978
|
+
for (const row of queued) {
|
|
979
|
+
tx.delete(Job, { id: row.id, state: State.Queued, payload: row.payload })
|
|
980
|
+
tx.insert(Job, { id: row.id, state: State.Running, payload: row.payload })
|
|
981
|
+
tx.insert(Lease, { job: row.id, worker: 7n, until: 60n })
|
|
982
|
+
}
|
|
983
|
+
return undefined
|
|
984
|
+
})
|
|
985
|
+
```
|
|
986
|
+
|
|
987
|
+
## 21. Derived relations
|
|
988
|
+
|
|
989
|
+
Guarantee: Lean theorem + validator/runtime premises for soundness
|
|
990
|
+
(`lean/Bumbledb/Txn.lean: derived_soundness_vs_freshness`); host
|
|
991
|
+
discipline for completeness — containment rejects unsupported facts but never refreshes omissions.
|
|
992
|
+
|
|
993
|
+
The materialized view as a relation under statements — unsoundness the schema
|
|
994
|
+
can name is uncommittable; incompleteness remains representable until the host
|
|
995
|
+
refreshes it.
|
|
996
|
+
|
|
997
|
+
```ts
|
|
998
|
+
const Arm = closed("Arm", ["Busy", "Ooo"])
|
|
999
|
+
const Claim = relation("Claim", { source: u64, person: u64, arm: Arm.id, span: interval(i64) })
|
|
1000
|
+
const BusySpan = relation("BusySpan", { person: u64, span: interval(i64) })
|
|
1001
|
+
|
|
1002
|
+
const Rollup = schema("Rollup", { Arm, Claim, BusySpan }, [
|
|
1003
|
+
contained(on(Claim, "arm"), on(Arm, "id")),
|
|
1004
|
+
key(Claim, ["source"]),
|
|
1005
|
+
key(Claim, ["person", "span"]),
|
|
1006
|
+
key(BusySpan, ["person", "span"]), // packed ⇒ disjoint: statable
|
|
1007
|
+
// Soundness, pointwise: every stored rollup point is covered by busy
|
|
1008
|
+
// claims — an UNSOUND rollup (claiming busy time that isn't, or surviving
|
|
1009
|
+
// its sources' deletion) cannot commit, judged on every touching commit.
|
|
1010
|
+
contained(on(BusySpan, ["person", "span"]), on(Claim.where({ arm: Arm.Busy }), ["person", "span"]))
|
|
1011
|
+
])
|
|
1012
|
+
|
|
1013
|
+
// Maintenance is the third witness idiom (recipe 20): re-run the deriving
|
|
1014
|
+
// query on a snapshot, diff, commit witnessed — the rollup cannot commit
|
|
1015
|
+
// against sources it didn't actually read. The deriving query (pack IS the
|
|
1016
|
+
// coalesce):
|
|
1017
|
+
const deriving = query(Rollup).rule((r) => {
|
|
1018
|
+
const { person, span } = r.vars("person", "span")
|
|
1019
|
+
return r.match(Claim, { person, span, arm: Arm.Busy }).select("person", r.pack("span"))
|
|
1020
|
+
})
|
|
1021
|
+
```
|
|
1022
|
+
|
|
1023
|
+
## 22. Union reads
|
|
1024
|
+
|
|
1025
|
+
Guarantee: Lean theorem + represented planner/runtime premise — rule union is
|
|
1026
|
+
set-idempotent (`lean/Bumbledb/Query/Denotation.lean: union_idempotent`);
|
|
1027
|
+
key-backed DU arms justify the disjointness optimization
|
|
1028
|
+
(`lean/Bumbledb/Exec/Dedup.lean: disjoint_witness_licence`).
|
|
1029
|
+
|
|
1030
|
+
The whole-DU read is a set of rules: one head, one rule per arm — disjunction
|
|
1031
|
+
is data at the top, never an execution node.
|
|
1032
|
+
|
|
1033
|
+
```ts
|
|
1034
|
+
const Kind = closed("Kind", ["Card", "Ach"])
|
|
1035
|
+
const Payment = relation("Payment", { id: u64.fresh, kind: Kind.id })
|
|
1036
|
+
const Card = relation("Card", { payment: u64, last4: u64 })
|
|
1037
|
+
const Ach = relation("Ach", { payment: u64, routing: u64 })
|
|
1038
|
+
|
|
1039
|
+
const Payments = schema("Payments", { Kind, Payment, Card, Ach }, [
|
|
1040
|
+
contained(on(Payment, "kind"), on(Kind, "id")),
|
|
1041
|
+
key(Card, ["payment"]),
|
|
1042
|
+
key(Ach, ["payment"]),
|
|
1043
|
+
mirrors(on(Payment.where({ kind: Kind.Card }), "id"), on(Card, "payment")),
|
|
1044
|
+
mirrors(on(Payment.where({ kind: Kind.Ach }), "id"), on(Ach, "payment"))
|
|
1045
|
+
])
|
|
1046
|
+
|
|
1047
|
+
// One query, two rules (set union). The exclusivity theorem (recipe 2) is
|
|
1048
|
+
// spent a third time here: rules selecting different `kind` handles are
|
|
1049
|
+
// provably disjoint, so the executor elides cross-rule dedup — the free lunch.
|
|
1050
|
+
const wholeDu = query(Payments)
|
|
1051
|
+
.rule((r) => {
|
|
1052
|
+
const { id, n } = r.vars("id", "n")
|
|
1053
|
+
return r
|
|
1054
|
+
.match(Payment, { id, kind: Kind.Card })
|
|
1055
|
+
.match(Card, { payment: id, last4: n })
|
|
1056
|
+
.select("id", "n")
|
|
1057
|
+
})
|
|
1058
|
+
.rule((r) => {
|
|
1059
|
+
const { id, n } = r.vars("id", "n")
|
|
1060
|
+
return r
|
|
1061
|
+
.match(Payment, { id, kind: Kind.Ach })
|
|
1062
|
+
.match(Ach, { payment: id, routing: n })
|
|
1063
|
+
.select("id", "n")
|
|
1064
|
+
})
|
|
1065
|
+
```
|
|
1066
|
+
|
|
1067
|
+
## 23. The anti-recipes: five gravestones
|
|
1068
|
+
|
|
1069
|
+
Guarantee: intentionally refused — each gravestone names unsupported vocabulary
|
|
1070
|
+
and its representable replacement; none asserts an engine theorem.
|
|
1071
|
+
|
|
1072
|
+
What not to model. Each gravestone cites its replacement; the block's
|
|
1073
|
+
relations are the replacements, compiled.
|
|
1074
|
+
|
|
1075
|
+
```ts
|
|
1076
|
+
// GRAVESTONE: successor pointers (a `next` column). A linked list inside a
|
|
1077
|
+
// relation is control flow smuggled into data. REPLACEMENT: the ordering
|
|
1078
|
+
// triple (recipe 9).
|
|
1079
|
+
const Step = relation("Step", { flow: u64, pos: u64, action: str })
|
|
1080
|
+
// GRAVESTONE: floats for scores, rates, money. Permanently refused (the
|
|
1081
|
+
// ledger). REPLACEMENT: fixed-point i64 — basis points (recipe 4).
|
|
1082
|
+
const Score = relation("Score", { subject: u64, bps: i64 })
|
|
1083
|
+
// GRAVESTONE: conditional keys ("at most one active run per student") —
|
|
1084
|
+
// rejected as FDs. REPLACEMENT: the relation split, whose ordinary key IS
|
|
1085
|
+
// the invariant (recipe 13's arm shape).
|
|
1086
|
+
const ActiveRun = relation("ActiveRun", { student: u64, run: u64 })
|
|
1087
|
+
// GRAVESTONE: clip-at-query intervals (facts spanning period boundaries,
|
|
1088
|
+
// every reader clipping). REPLACEMENT: split at write (recipe 17).
|
|
1089
|
+
const Usage = relation("Usage", { meter: u64, period: u64, used: interval(i64) })
|
|
1090
|
+
// GRAVESTONE: uuid keys. uuidv7 is identity + clash-avoidance + clock in
|
|
1091
|
+
// one lie. REPLACEMENT: fresh (minted identity) + an explicit i64 time column.
|
|
1092
|
+
const Event = relation("Event", { id: u64.fresh, at: i64 })
|
|
1093
|
+
|
|
1094
|
+
const Gravestones = schema("Gravestones", { Step, Score, ActiveRun, Usage, Event }, [
|
|
1095
|
+
key(Step, ["flow", "pos"]),
|
|
1096
|
+
key(Score, ["subject"]),
|
|
1097
|
+
key(ActiveRun, ["student"]),
|
|
1098
|
+
key(Usage, ["meter", "used"])
|
|
1099
|
+
])
|
|
1100
|
+
```
|
|
1101
|
+
|
|
1102
|
+
## Host-driven closure
|
|
1103
|
+
|
|
1104
|
+
## 24. The closure idiom
|
|
1105
|
+
|
|
1106
|
+
Guarantee: host discipline for the loop — the finite `seen` set proves
|
|
1107
|
+
termination for the host run; the engine-native form beside it executes
|
|
1108
|
+
whole under the fixpoint driver, budget-bounded
|
|
1109
|
+
(`lean/Bumbledb/Exec/Fixpoint.lean: program_eval_sound`).
|
|
1110
|
+
|
|
1111
|
+
Reachability, in two dialects. The host-loop idiom remains the depth-bounded
|
|
1112
|
+
answer: the loop runs depth-many rounds and each round is one ∈-set query —
|
|
1113
|
+
an `inSet` probe, microsecond-class. The frontier discipline below *is*
|
|
1114
|
+
semi-naive evaluation's Δ, spent where a loop is a loop: the host. The
|
|
1115
|
+
engine-native form is the same closure as one stratified `program()`.
|
|
1116
|
+
|
|
1117
|
+
```ts
|
|
1118
|
+
const Node = relation("Node", { id: u64.fresh, name: str })
|
|
1119
|
+
// One parent per child — a forest (recipe 10's edge shape); a root is a
|
|
1120
|
+
// node whose Parent fact is absent (recipe 3's honest 0..1).
|
|
1121
|
+
const Parent = relation("Parent", { child: u64, parent: u64 })
|
|
1122
|
+
|
|
1123
|
+
const Closure = schema("Closure", { Node, Parent }, [
|
|
1124
|
+
key(Parent, ["child"]),
|
|
1125
|
+
contained(on(Parent, "child"), on(Node, "id")),
|
|
1126
|
+
contained(on(Parent, "parent"), on(Node, "id"))
|
|
1127
|
+
])
|
|
1128
|
+
|
|
1129
|
+
// The loop's one query — the frontier's children, one ∈-set probe:
|
|
1130
|
+
const step = query(Closure).rule((r) => {
|
|
1131
|
+
const { c } = r.vars("c")
|
|
1132
|
+
return r.match(Parent, { child: c, parent: r.inSet("frontier") }).select("c")
|
|
1133
|
+
})
|
|
1134
|
+
```
|
|
1135
|
+
|
|
1136
|
+
The loop (the compiled, driven copy is in `test/cookbook.test.ts`, over a
|
|
1137
|
+
three-level forest with the exact reachable set asserted):
|
|
1138
|
+
|
|
1139
|
+
```ts
|
|
1140
|
+
const db = await Db.create("./closure.db", Closure)
|
|
1141
|
+
const stepPrepared = db.prepare(step)
|
|
1142
|
+
const root = 1n // the host's chosen root node id
|
|
1143
|
+
|
|
1144
|
+
const seen = new Set<bigint>([root])
|
|
1145
|
+
let frontier: readonly bigint[] = [root]
|
|
1146
|
+
for (;;) {
|
|
1147
|
+
const next = db.execute(stepPrepared, { frontier }) // one set-param query
|
|
1148
|
+
const fresh = next
|
|
1149
|
+
.map((row) => row.c)
|
|
1150
|
+
.filter((c) => {
|
|
1151
|
+
return !seen.has(c)
|
|
1152
|
+
})
|
|
1153
|
+
if (fresh.length === 0) {
|
|
1154
|
+
break
|
|
1155
|
+
}
|
|
1156
|
+
for (const c of fresh) {
|
|
1157
|
+
seen.add(c)
|
|
1158
|
+
}
|
|
1159
|
+
frontier = fresh
|
|
1160
|
+
}
|
|
1161
|
+
```
|
|
1162
|
+
|
|
1163
|
+
Termination is the host's theorem: `seen` grows strictly or the loop breaks,
|
|
1164
|
+
inside a finite node set. When the idiom's costs bite — **unbounded or large
|
|
1165
|
+
depth**, or **closure composed into a larger plan** — write the engine-native
|
|
1166
|
+
form instead: `?root` seeds the predicate, and the output joins the finished
|
|
1167
|
+
set back through the theory's own domain relation (an `idb` atom is a join
|
|
1168
|
+
position, so the head rides the `Node` atom):
|
|
1169
|
+
|
|
1170
|
+
```ts
|
|
1171
|
+
const reach = program(Closure, (p) => {
|
|
1172
|
+
const rec = p.rec("reach")
|
|
1173
|
+
const seeded = rec
|
|
1174
|
+
.rule((r) => {
|
|
1175
|
+
const { c } = r.vars("c")
|
|
1176
|
+
return r
|
|
1177
|
+
.match(Node, { id: c })
|
|
1178
|
+
.where(eq(c, r.param("root")))
|
|
1179
|
+
.select("c")
|
|
1180
|
+
})
|
|
1181
|
+
.rule((r) => {
|
|
1182
|
+
const { c, parent } = r.vars("c", "parent")
|
|
1183
|
+
return r
|
|
1184
|
+
.match(Parent, { child: c, parent })
|
|
1185
|
+
.idb(rec, parent)
|
|
1186
|
+
.select("c")
|
|
1187
|
+
})
|
|
1188
|
+
return p.output((r) => {
|
|
1189
|
+
const { c } = r.vars("c")
|
|
1190
|
+
return r.match(Node, { id: c }).idb(seeded, c).select("c")
|
|
1191
|
+
})
|
|
1192
|
+
})
|
|
1193
|
+
const reachPrepared = db.prepare(reach)
|
|
1194
|
+
```
|
|
1195
|
+
|
|
1196
|
+
(the test drives both dialects and asserts the same reachable sets, root for
|
|
1197
|
+
root). What stays host-side is the **chain-window class** — interval
|
|
1198
|
+
intersection along paths — which the recursion surface fences out: the idiom
|
|
1199
|
+
carries the window in the host's frontier, one intersection per hop, and that
|
|
1200
|
+
composition has no engine form.
|
|
1201
|
+
|
|
1202
|
+
## 25. The chart of accounts
|
|
1203
|
+
|
|
1204
|
+
Guarantee: host discipline + runtime aggregate semantics — the host computes
|
|
1205
|
+
closure, then one checked `sum` (`lean/Bumbledb/Query/Aggregates.lean:
|
|
1206
|
+
checkedSum_sound`); the engine-native form folds over a *finished* lower
|
|
1207
|
+
stratum, the one aggregation shape the strata roster admits.
|
|
1208
|
+
|
|
1209
|
+
The ledger workload's real recursion case, in the same two dialects: a
|
|
1210
|
+
hierarchical chart of accounts and a subtree rollup. The host composition —
|
|
1211
|
+
recipe 24's loop accumulates the subtree's ∈-set, then **one `sum` query over
|
|
1212
|
+
the accumulated set** folds the postings. The engine aggregates, the host
|
|
1213
|
+
composes (aggregates never nest). The engine-native form is one program:
|
|
1214
|
+
aggregation *through* a cycle is refused, but a fold over a recursive
|
|
1215
|
+
predicate from a **higher stratum** reads a finished set and is ordinary.
|
|
1216
|
+
|
|
1217
|
+
```ts
|
|
1218
|
+
const Account = relation("Account", { id: u64.fresh, name: str })
|
|
1219
|
+
const AccountParent = relation("AccountParent", { child: u64, parent: u64 })
|
|
1220
|
+
const Posting = relation("Posting", { id: u64.fresh, account: u64, minor: i64 })
|
|
1221
|
+
|
|
1222
|
+
const Accounts = schema("Accounts", { Account, AccountParent, Posting }, [
|
|
1223
|
+
key(AccountParent, ["child"]), // one parent per account
|
|
1224
|
+
contained(on(AccountParent, "child"), on(Account, "id")),
|
|
1225
|
+
contained(on(AccountParent, "parent"), on(Account, "id")),
|
|
1226
|
+
contained(on(Posting, "account"), on(Account, "id"))
|
|
1227
|
+
])
|
|
1228
|
+
|
|
1229
|
+
// The two queries the host rollup composes:
|
|
1230
|
+
// the frontier step (recipe 24's loop, verbatim):
|
|
1231
|
+
const frontierStep = query(Accounts).rule((r) => {
|
|
1232
|
+
const { c } = r.vars("c")
|
|
1233
|
+
return r.match(AccountParent, { child: c, parent: r.inSet("frontier") }).select("c")
|
|
1234
|
+
})
|
|
1235
|
+
// the rollup over the accumulated subtree (bind the fresh id — recipe
|
|
1236
|
+
// 19's discipline, spent again; equal postings to one account both count):
|
|
1237
|
+
const subtreeRollup = query(Accounts).rule((r) => {
|
|
1238
|
+
const { id, minor } = r.vars("id", "minor")
|
|
1239
|
+
return r.match(Posting, { id, account: r.inSet("subtree"), minor }).select(r.sum("minor"))
|
|
1240
|
+
})
|
|
1241
|
+
// The engine-native form: the closure stratum converges first, then the
|
|
1242
|
+
// output's fold runs once over the finished subtree.
|
|
1243
|
+
const nativeRollup = program(Accounts, (p) => {
|
|
1244
|
+
const sub = p.rec("sub")
|
|
1245
|
+
const seeded = sub
|
|
1246
|
+
.rule((r) => {
|
|
1247
|
+
const { a } = r.vars("a")
|
|
1248
|
+
return r
|
|
1249
|
+
.match(Account, { id: a })
|
|
1250
|
+
.where(eq(a, r.param("root")))
|
|
1251
|
+
.select("a")
|
|
1252
|
+
})
|
|
1253
|
+
.rule((r) => {
|
|
1254
|
+
const { a, parent } = r.vars("a", "parent")
|
|
1255
|
+
return r
|
|
1256
|
+
.match(AccountParent, { child: a, parent })
|
|
1257
|
+
.idb(sub, parent)
|
|
1258
|
+
.select("a")
|
|
1259
|
+
})
|
|
1260
|
+
return p.output((r) => {
|
|
1261
|
+
const { id, a, minor } = r.vars("id", "a", "minor")
|
|
1262
|
+
return r
|
|
1263
|
+
.match(Posting, { id, account: a, minor })
|
|
1264
|
+
.idb(seeded, a)
|
|
1265
|
+
.select(r.sum("minor"))
|
|
1266
|
+
})
|
|
1267
|
+
})
|
|
1268
|
+
```
|
|
1269
|
+
|
|
1270
|
+
## 26. Exact partition
|
|
1271
|
+
|
|
1272
|
+
Guarantee: Lean theorem + validator/runtime premises — mutual point coverage
|
|
1273
|
+
plus pointwise keys realizes exact partition
|
|
1274
|
+
(`lean/Bumbledb/Dependencies.lean: exact_partition_iff`).
|
|
1275
|
+
|
|
1276
|
+
An exact partition needs both coverage directions. The first containment below
|
|
1277
|
+
is the intent-level reference; the two pointwise keys make each side disjoint;
|
|
1278
|
+
the final pair proves equal point supports per policy — forward coverage
|
|
1279
|
+
forbids gaps and reverse coverage forbids overhang.
|
|
1280
|
+
|
|
1281
|
+
The explicit `key(Policy, ["id", "live"])` is load-bearing. Containment
|
|
1282
|
+
targets resolve by their exact projected field set, so the fresh `{id}` key
|
|
1283
|
+
cannot serve the `{id, live}` target and the engine infers no key closure.
|
|
1284
|
+
|
|
1285
|
+
```ts
|
|
1286
|
+
const Policy = relation("Policy", { id: u64.fresh, live: interval(i64) })
|
|
1287
|
+
const Version = relation("Version", { policy: u64, valid: interval(i64) })
|
|
1288
|
+
|
|
1289
|
+
const ExactPartition = schema("ExactPartition", { Policy, Version }, [
|
|
1290
|
+
contained(on(Version, "policy"), on(Policy, "id")), // reference intent
|
|
1291
|
+
key(Version, ["policy", "valid"]), // disjoint versions
|
|
1292
|
+
key(Policy, ["id", "live"]), // exact target key, not implied by {id}
|
|
1293
|
+
contained(on(Policy, ["id", "live"]), on(Version, ["policy", "valid"])), // no gaps in the policy source span
|
|
1294
|
+
contained(on(Version, ["policy", "valid"]), on(Policy, ["id", "live"])) // no version overhang
|
|
1295
|
+
])
|
|
1296
|
+
```
|
|
1297
|
+
|
|
1298
|
+
Together the mutual containments prove equal point supports for each policy;
|
|
1299
|
+
the pointwise keys make those supports genuine partitions rather than
|
|
1300
|
+
overlapping covers. Touching half-open segments remain legal, and the same
|
|
1301
|
+
construction works with any scalar-prefix arity before the final interval
|
|
1302
|
+
position.
|
|
1303
|
+
|
|
1304
|
+
## 27. Derived facts, maintained
|
|
1305
|
+
|
|
1306
|
+
Guarantee: host discipline + validator/runtime premises — freshness comes from
|
|
1307
|
+
the generation witness; containment proves surviving rollup facts sound only
|
|
1308
|
+
(`lean/Bumbledb/Txn.lean: derived_soundness_vs_freshness`).
|
|
1309
|
+
|
|
1310
|
+
A stored rollup is an ordinary relation with an ordinary soundness statement.
|
|
1311
|
+
Here `pack` derives maximal busy spans, while containment prevents any stored
|
|
1312
|
+
`BusySpan` point that has no busy claim behind it. That is soundness, not a
|
|
1313
|
+
refresh theorem: a missing span remains representable until the host
|
|
1314
|
+
maintenance loop fills it.
|
|
1315
|
+
|
|
1316
|
+
```ts
|
|
1317
|
+
const Arm = closed("Arm", ["Busy", "Ooo"])
|
|
1318
|
+
const Claim = relation("Claim", { source: u64, person: u64, arm: Arm.id, span: interval(i64) })
|
|
1319
|
+
const BusySpan = relation("BusySpan", { person: u64, span: interval(i64) })
|
|
1320
|
+
|
|
1321
|
+
const MaintainedRollup = schema("MaintainedRollup", { Arm, Claim, BusySpan }, [
|
|
1322
|
+
contained(on(Claim, "arm"), on(Arm, "id")),
|
|
1323
|
+
key(Claim, ["source"]),
|
|
1324
|
+
key(Claim, ["person", "span"]),
|
|
1325
|
+
key(BusySpan, ["person", "span"]),
|
|
1326
|
+
contained(on(BusySpan, ["person", "span"]), on(Claim.where({ arm: Arm.Busy }), ["person", "span"]))
|
|
1327
|
+
])
|
|
1328
|
+
|
|
1329
|
+
// Derive the desired rollup on the maintenance snapshot:
|
|
1330
|
+
const deriving = query(MaintainedRollup).rule((r) => {
|
|
1331
|
+
const { source, person, span } = r.vars("source", "person", "span")
|
|
1332
|
+
return r.match(Claim, { source, person, arm: Arm.Busy, span }).select("person", r.pack("span"))
|
|
1333
|
+
})
|
|
1334
|
+
```
|
|
1335
|
+
|
|
1336
|
+
The host loop is `db.writeWitnessed`: derive on the attempt's snapshot, diff,
|
|
1337
|
+
build the delta — recipe 20's third idiom. On a moved generation the SDK
|
|
1338
|
+
throws away the attempt and reruns the whole callback on a fresh snapshot; it
|
|
1339
|
+
never retries a stale diff. Dependencies prove every surviving stored span
|
|
1340
|
+
sound, while the witness proves which source state the derivation saw;
|
|
1341
|
+
neither mechanism proves completeness. The engine's compiled copy
|
|
1342
|
+
(`maintain_busy_spans` in `cookbook.rs`) drives the retry-and-repack loop.
|
|
1343
|
+
|
|
1344
|
+
## Operating the store
|
|
1345
|
+
|
|
1346
|
+
## 28. Migration is ETL
|
|
1347
|
+
|
|
1348
|
+
Guarantee: Lean theorem + validator/runtime premises + host discipline —
|
|
1349
|
+
fingerprints refuse reinterpretation, final-state judgments validate each load
|
|
1350
|
+
(`lean/Bumbledb/Txn.lean: etl_lands_valid`), and the host owns
|
|
1351
|
+
the semantic transform and dependency-safe load order.
|
|
1352
|
+
|
|
1353
|
+
There is no in-place migration and never will be: a schema is a theory, the
|
|
1354
|
+
store records the theory's fingerprint, and `Db.open` under a changed theory
|
|
1355
|
+
is a hard fingerprint mismatch — the engine refuses to reinterpret facts it
|
|
1356
|
+
judged under different laws. Migration is extract, transform, load: `scan`
|
|
1357
|
+
exports every fact of a relation as typed values under one snapshot, the host
|
|
1358
|
+
transforms, and inserts (fresh ids resupplied — identity survives) land into
|
|
1359
|
+
a store created under the new theory, judged whole by the ordinary
|
|
1360
|
+
final-state judgment: load containment targets first, and a chunk that lands
|
|
1361
|
+
is already valid.
|
|
1362
|
+
|
|
1363
|
+
The v2 theory below adds what v1 never recorded — *when* a salary applied —
|
|
1364
|
+
as an interval with a pointwise key: one salary per employee per instant. The
|
|
1365
|
+
transform supplies the missing dimension (a ray from the migration epoch).
|
|
1366
|
+
|
|
1367
|
+
```ts
|
|
1368
|
+
// The old theory, judged and fingerprinted:
|
|
1369
|
+
const EmployeeV1 = relation("Employee", { id: u64.fresh, name: str })
|
|
1370
|
+
const SalaryV1 = relation("Salary", { employee: u64, amount: i64 })
|
|
1371
|
+
const PayrollV1 = schema("PayrollV1", { Employee: EmployeeV1, Salary: SalaryV1 }, [
|
|
1372
|
+
contained(on(SalaryV1, "employee"), on(EmployeeV1, "id"))
|
|
1373
|
+
])
|
|
1374
|
+
|
|
1375
|
+
// The new theory adds what v1 never recorded:
|
|
1376
|
+
const Employee = relation("Employee", { id: u64.fresh, name: str })
|
|
1377
|
+
const Salary = relation("Salary", { employee: u64, amount: i64, applies: interval(i64) })
|
|
1378
|
+
const Payroll = schema("Payroll", { Employee, Salary }, [
|
|
1379
|
+
contained(on(Salary, "employee"), on(Employee, "id")),
|
|
1380
|
+
key(Salary, ["employee", "applies"]) // one salary per instant
|
|
1381
|
+
])
|
|
1382
|
+
|
|
1383
|
+
// The post-migration read — salaries in force at an instant:
|
|
1384
|
+
const inForceAt = query(Payroll).rule((r) => {
|
|
1385
|
+
const { e, name, amount, w } = r.vars("e", "name", "amount", "w")
|
|
1386
|
+
return r
|
|
1387
|
+
.match(Employee, { id: e, name })
|
|
1388
|
+
.match(Salary, { employee: e, amount, applies: w })
|
|
1389
|
+
.where(pointIn(r.param("at"), w))
|
|
1390
|
+
.select("name", "amount")
|
|
1391
|
+
})
|
|
1392
|
+
```
|
|
1393
|
+
|
|
1394
|
+
The engine's compiled test drives the whole loop (export under one snapshot,
|
|
1395
|
+
the fingerprint refusal, load order, identity, mint catch-up, judgment); the
|
|
1396
|
+
SDK pin asserts the two theories carry two distinct fingerprints — the
|
|
1397
|
+
refusal's premise. For stores whose creating schema is gone, `Db.exhume`
|
|
1398
|
+
reads the store's own persisted descriptor — the one schema-independent read
|
|
1399
|
+
path, the E half of ETL.
|
|
1400
|
+
|
|
1401
|
+
## Composition
|
|
1402
|
+
|
|
1403
|
+
## 29. The zone ledger
|
|
1404
|
+
|
|
1405
|
+
Guarantee: Lean theorem + validator/runtime premises — per-kind mutual point
|
|
1406
|
+
coverage realizes each arm's exact partition
|
|
1407
|
+
(`lean/Bumbledb/Dependencies.lean: exact_partition_iff`) over one
|
|
1408
|
+
disjointness witness, and the mixed-width `==` positions type by element
|
|
1409
|
+
domain (`lean/Bumbledb/Schema.lean: Value.points_one_tag_u64`); witness
|
|
1410
|
+
segmentation is host discipline (the honesty note below).
|
|
1411
|
+
|
|
1412
|
+
Recipe 9's sidecar, composed: a ledger whose timeline divides into zones of
|
|
1413
|
+
two kinds — unit zones (`interval(u64, 1n)`) and pair zones
|
|
1414
|
+
(`interval(u64, 2n)`), each kind carrying its own payload sidecar. The
|
|
1415
|
+
discriminated-union pattern (recipe 2) applied at interval positions: a
|
|
1416
|
+
kind-discriminated `Zone` witness relation owns **cross-sidecar disjointness**
|
|
1417
|
+
through its one pointwise key, and since each sidecar's point support equals
|
|
1418
|
+
its kind's zone support (the per-kind `mirrors`), a unit slot can never
|
|
1419
|
+
overlap a pair slot even though they live in different relations. The arm
|
|
1420
|
+
widths are enforced **by type**: a `UnitSlot` value is width 1 or does not
|
|
1421
|
+
exist — no runtime width check, nothing to enforce at commit.
|
|
1422
|
+
|
|
1423
|
+
```ts
|
|
1424
|
+
const Kind = closed("Kind", ["Unit", "Pair"])
|
|
1425
|
+
const Ledger = relation("Ledger", { id: u64.fresh, name: str })
|
|
1426
|
+
// The witness: every zone of the ledger, kind-discriminated; its one
|
|
1427
|
+
// pointwise key is the cross-sidecar disjointness proof.
|
|
1428
|
+
const Zone = relation("Zone", { ledger: u64, kind: Kind.id, at: interval(u64) })
|
|
1429
|
+
const UnitSlot = relation("UnitSlot", { ledger: u64, at: interval(u64, 1n), entry: u64 })
|
|
1430
|
+
const PairSlot = relation("PairSlot", { ledger: u64, at: interval(u64, 2n), entry: u64 })
|
|
1431
|
+
|
|
1432
|
+
const ZoneLedger = schema("ZoneLedger", { Kind, Ledger, Zone, UnitSlot, PairSlot }, [
|
|
1433
|
+
contained(on(Zone, "ledger"), on(Ledger, "id")),
|
|
1434
|
+
contained(on(Zone, "kind"), on(Kind, "id")),
|
|
1435
|
+
key(Zone, ["ledger", "at"]), // all zones disjoint, whatever the kind
|
|
1436
|
+
key(UnitSlot, ["ledger", "at"]),
|
|
1437
|
+
key(PairSlot, ["ledger", "at"]),
|
|
1438
|
+
// Each kind's zones carry exactly its sidecar's points — mixed widths,
|
|
1439
|
+
// one element domain:
|
|
1440
|
+
mirrors(on(Zone.where({ kind: Kind.Unit }), ["ledger", "at"]), on(UnitSlot, ["ledger", "at"])),
|
|
1441
|
+
mirrors(on(Zone.where({ kind: Kind.Pair }), ["ledger", "at"]), on(PairSlot, ["ledger", "at"]))
|
|
1442
|
+
])
|
|
1443
|
+
```
|
|
1444
|
+
|
|
1445
|
+
The honesty note — **coalescing insensitivity**: the `mirrors` judgments
|
|
1446
|
+
compare point supports, not rows. A single Unit-kind zone `[4,6)` beside two
|
|
1447
|
+
unit slots `[4,5)`, `[5,6)` satisfies both directions, because nothing forces
|
|
1448
|
+
the witness rows to mirror the sidecar's segmentation — only its points. If
|
|
1449
|
+
per-row correspondence matters, the host writes zones at slot granularity;
|
|
1450
|
+
the schema proves disjointness and coverage either way.
|