@bjornpagen/bumbledb 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.
Files changed (94) hide show
  1. package/COOKBOOK.md +503 -427
  2. package/README.md +84 -36
  3. package/dist/closed.d.ts +111 -38
  4. package/dist/closed.d.ts.map +1 -1
  5. package/dist/closed.js +94 -99
  6. package/dist/closed.js.map +1 -1
  7. package/dist/db.d.ts +16 -2
  8. package/dist/db.d.ts.map +1 -1
  9. package/dist/db.js +46 -8
  10. package/dist/db.js.map +1 -1
  11. package/dist/face.d.ts +114 -69
  12. package/dist/face.d.ts.map +1 -1
  13. package/dist/face.js +38 -21
  14. package/dist/face.js.map +1 -1
  15. package/dist/fields.d.ts +72 -87
  16. package/dist/fields.d.ts.map +1 -1
  17. package/dist/fields.js +35 -67
  18. package/dist/fields.js.map +1 -1
  19. package/dist/index.d.ts +18 -13
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +11 -7
  22. package/dist/index.js.map +1 -1
  23. package/dist/law.d.ts +224 -0
  24. package/dist/law.d.ts.map +1 -0
  25. package/dist/law.js +224 -0
  26. package/dist/law.js.map +1 -0
  27. package/dist/lower.d.ts +17 -10
  28. package/dist/lower.d.ts.map +1 -1
  29. package/dist/lower.js +34 -23
  30. package/dist/lower.js.map +1 -1
  31. package/dist/marshal.d.ts +33 -6
  32. package/dist/marshal.d.ts.map +1 -1
  33. package/dist/marshal.js +67 -6
  34. package/dist/marshal.js.map +1 -1
  35. package/dist/native.d.ts +6 -2
  36. package/dist/native.d.ts.map +1 -1
  37. package/dist/native.js.map +1 -1
  38. package/dist/query/atom.d.ts +139 -56
  39. package/dist/query/atom.d.ts.map +1 -1
  40. package/dist/query/atom.js +7 -1
  41. package/dist/query/atom.js.map +1 -1
  42. package/dist/query/lower.d.ts +71 -56
  43. package/dist/query/lower.d.ts.map +1 -1
  44. package/dist/query/lower.js +341 -69
  45. package/dist/query/lower.js.map +1 -1
  46. package/dist/query/predicate.d.ts +10 -9
  47. package/dist/query/predicate.d.ts.map +1 -1
  48. package/dist/query/predicate.js +2 -2
  49. package/dist/query/predicate.js.map +1 -1
  50. package/dist/query/run.d.ts +15 -5
  51. package/dist/query/run.d.ts.map +1 -1
  52. package/dist/query/run.js +26 -6
  53. package/dist/query/run.js.map +1 -1
  54. package/dist/query/scope.d.ts +100 -43
  55. package/dist/query/scope.d.ts.map +1 -1
  56. package/dist/query/scope.js +89 -30
  57. package/dist/query/scope.js.map +1 -1
  58. package/dist/query/select.d.ts +5 -5
  59. package/dist/query/select.d.ts.map +1 -1
  60. package/dist/relation.d.ts +29 -15
  61. package/dist/relation.d.ts.map +1 -1
  62. package/dist/relation.js +45 -17
  63. package/dist/relation.js.map +1 -1
  64. package/dist/schema.d.ts +41 -3
  65. package/dist/schema.d.ts.map +1 -1
  66. package/dist/schema.js +16 -2
  67. package/dist/schema.js.map +1 -1
  68. package/dist/spec.d.ts +10 -8
  69. package/dist/spec.d.ts.map +1 -1
  70. package/dist/spec.js.map +1 -1
  71. package/dist/statements.d.ts +67 -31
  72. package/dist/statements.d.ts.map +1 -1
  73. package/dist/statements.js +97 -20
  74. package/dist/statements.js.map +1 -1
  75. package/package.json +2 -2
  76. package/src/closed.ts +214 -146
  77. package/src/db.ts +65 -10
  78. package/src/face.ts +169 -102
  79. package/src/fields.ts +97 -164
  80. package/src/index.ts +43 -18
  81. package/src/law.ts +519 -0
  82. package/src/lower.ts +36 -23
  83. package/src/marshal.ts +74 -7
  84. package/src/native.ts +6 -2
  85. package/src/query/atom.ts +155 -65
  86. package/src/query/lower.ts +572 -167
  87. package/src/query/predicate.ts +43 -33
  88. package/src/query/run.ts +26 -6
  89. package/src/query/scope.ts +161 -51
  90. package/src/query/select.ts +5 -5
  91. package/src/relation.ts +60 -26
  92. package/src/schema.ts +48 -7
  93. package/src/spec.ts +10 -8
  94. package/src/statements.ts +165 -46
package/COOKBOOK.md CHANGED
@@ -8,12 +8,19 @@ theory in another skin, and the engine cookbook's deference chain
8
8
  (`10-data-model.md`, `30-dependencies.md`, `20-query-ir.md`, `70-api.md`)
9
9
  applies here unchanged.
10
10
 
11
- Every schema below compiles and validates verbatim against the current SDK
12
- and engine — `ts/test/cookbook.test.ts` constructs each one through the
13
- public surface, admits it on a real store (the engine's schema validation is
14
- the acceptance judgment), asserts its fingerprint stable across a reopen, and
15
- lowers every query snippet through `db.prepare` (the engine's own IR
16
- validation). A recipe that stops compiling against the SDK fails the build.
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).
17
24
 
18
25
  Guarantee labels that name Lean results cite the checked spec in the engine
19
26
  repo's `lean/` by theorem name, exactly as the engine cookbook does
@@ -26,28 +33,60 @@ Everything below imports from the one package entry:
26
33
  import {
27
34
  ALLEN,
28
35
  Db,
36
+ type Infer,
29
37
  abandon,
38
+ allen,
30
39
  bool,
31
40
  bytes,
32
41
  closed,
33
42
  contained,
43
+ eq,
34
44
  i64,
35
45
  interval,
36
46
  key,
47
+ lt,
37
48
  mirrors,
38
- none,
49
+ not,
39
50
  on,
40
- oneOf,
51
+ pointIn,
41
52
  program,
42
53
  query,
43
54
  relation,
44
55
  schema,
45
56
  str,
46
- u64,
47
- window
57
+ u64
48
58
  } from "@bjornpagen/bumbledb"
49
59
  ```
50
60
 
61
+ ## The class laws — the statements type the columns
62
+
63
+ Relation declarations are pure structure — `kind`, `width`, `element`,
64
+ `fresh`, nothing else. Domains are never declared: `schema()` computes every
65
+ field's equivalence class FROM the statement list, by union-find across every
66
+ paired face (containment, `mirrors`, window targets, ψ-selected faces
67
+ included). Three laws govern the classes:
68
+
69
+ 1. **A fresh field is a generator** and names its class by its declaration
70
+ coordinate (`"Attempt.id"`); a closed relation's id is a generator named
71
+ the same way (`"Kind.id"`).
72
+ 2. **A generator-less class** is named by its least member coordinate in
73
+ relation-declaration × field-declaration order (recipe 5's
74
+ `"Document.payload"`), deterministic and pinned forever.
75
+ 3. **A field in no law is bare, and bare pairs only with bare** in queries —
76
+ a deliberate sum-domain pointer stays legal because you simply write no
77
+ law over it.
78
+
79
+ The wall: at most ONE generator per class — a statement list that unifies two
80
+ fresh coordinates is a contradiction (two mints cannot share a carrier), a
81
+ schema-level compile error with a construction-time runtime twin. The
82
+ dividend is that the statements you already write ARE the typing: a query
83
+ variable first bound in one class refuses to join a field of another (compile
84
+ error, with the same refusal thrown at construction), and no label, link, or
85
+ domain declaration exists anywhere on the surface. When a recipe below says a
86
+ statement "types" a column, this machinery is what it means. Classes never
87
+ touch the fingerprint — identity hashes canonical descriptor bytes, never the
88
+ law-computed names.
89
+
51
90
  ## Foundations
52
91
 
53
92
  ## 1. The minimal interval schema
@@ -60,38 +99,45 @@ pointwise_key_disjoint`); checked intervals supply nonempty values
60
99
  One fact per outage window; the pointwise key is the whole temporal design.
61
100
 
62
101
  ```ts
63
- const ServiceId = u64.as("ServiceId")
64
-
65
- const Service = relation("Service", { id: ServiceId.fresh, name: str })
102
+ const Service = relation("Service", { id: u64.fresh, name: str })
66
103
  // The window is one value, not a (start, end) column pair: the denotation
67
104
  // (a set of points, half-open) is what the judgments below read through.
68
- const Outage = relation("Outage", { service: ServiceId, window: interval(i64) })
105
+ // The containment below is what types `service` the class laws.
106
+ const Outage = relation("Outage", { service: u64, window: interval(i64) })
69
107
 
70
108
  const Uptime = schema("Uptime", { Service, Outage }, [
71
109
  contained(on(Outage, "service"), on(Service, "id")),
72
110
  // The pointwise key: per service, no two outages share a point — every
73
111
  // pair satisfies DISJOINT. SQL:2011's WITHOUT OVERLAPS, as a theorem.
112
+ // `key(R, [...])` is the host flavor of the canonical arrow — this one
113
+ // renders `Outage(service, window) -> Outage`: the key projection
114
+ // determines the tuple, and the arrow closing over its own relation is
115
+ // what makes a key a key (the compile-pin asserts the render).
74
116
  key(Outage, ["service", "window"])
75
117
  ])
76
118
 
77
- // down at instant t (point membership is a typing rule):
78
- const downAt = query(Uptime).rule((r) =>
79
- r
80
- .match(Outage, { service: r.var("service"), window: r.var("w") })
81
- .where(r.pointIn(r.param("t"), r.var("w")))
119
+ // down at instant t `r.vars` mints the rule's whole variable scope at
120
+ // once, and shorthand punning binds same-named columns:
121
+ const downAt = query(Uptime).rule((r) => {
122
+ const { service, window } = r.vars("service", "window")
123
+ return r
124
+ .match(Outage, { service, window })
125
+ .where(pointIn(r.param("t"), window))
82
126
  .select("service")
83
- )
127
+ })
84
128
  // overlapping an incident window (one Allen mask, no operator zoo):
85
- const overlapping = query(Uptime).rule((r) =>
86
- r
87
- .match(Outage, { service: r.var("service"), window: r.var("w") })
88
- .where(r.allen(r.var("w"), ALLEN.intersects, r.param("incident")))
89
- .select("service", "w")
90
- )
129
+ const overlapping = query(Uptime).rule((r) => {
130
+ const { service, window } = r.vars("service", "window")
131
+ return r
132
+ .match(Outage, { service, window })
133
+ .where(allen(window, ALLEN.intersects, r.param("incident")))
134
+ .select("service", "window")
135
+ })
91
136
  // total downtime per service (the denotation's one arithmetic):
92
- const downtime = query(Uptime).rule((r) =>
93
- r.match(Outage, { service: r.var("service"), window: r.var("w") }).select("service", r.sum(r.duration("w")))
94
- )
137
+ const downtime = query(Uptime).rule((r) => {
138
+ const { service, window } = r.vars("service", "window")
139
+ return r.match(Outage, { service, window }).select("service", r.sum(r.duration("window")))
140
+ })
95
141
  ```
96
142
 
97
143
  ## 2. Discriminated unions
@@ -104,15 +150,13 @@ Sum-typed entities: a closed-relation discriminator plus per-arm child
104
150
  relations, glued by bidirectional conditional containments.
105
151
 
106
152
  ```ts
107
- const TaskId = u64.as("TaskId")
108
-
109
153
  // The discriminator vocabulary is a closed relation: its ground axioms are
110
- // axioms, and the handle constants (`Kind.Deterministic`, bare bigints) are
111
- // the literals on every surface.
154
+ // axioms, and a handle is its NAME the string literal "Deterministic" is
155
+ // the ONE spelling, on every surface (statements, inserts, queries, rows).
112
156
  const Kind = closed("Kind", ["Deterministic", "CustomOperator"])
113
- const Task = relation("Task", { id: TaskId.fresh, kind: Kind.id })
114
- const DeterministicGrading = relation("DeterministicGrading", { task: TaskId, tolerance: i64 })
115
- const CustomOperatorGrading = relation("CustomOperatorGrading", { task: TaskId, operator: str })
157
+ const Task = relation("Task", { id: u64.fresh, kind: Kind.id })
158
+ const DeterministicGrading = relation("DeterministicGrading", { task: u64, tolerance: i64 })
159
+ const CustomOperatorGrading = relation("CustomOperatorGrading", { task: u64, operator: str })
116
160
 
117
161
  const Grading = schema("Grading", { Kind, Task, DeterministicGrading, CustomOperatorGrading }, [
118
162
  contained(on(Task, "kind"), on(Kind, "id")), // the discriminator resolves
@@ -120,13 +164,31 @@ const Grading = schema("Grading", { Kind, Task, DeterministicGrading, CustomOper
120
164
  key(CustomOperatorGrading, ["task"]),
121
165
  // Totality (==, left to right): a Deterministic task HAS its arm fact —
122
166
  // same commit, always. Arm validity (right to left): an arm fact's parent
123
- // exists WITH that kind — composite-FK-plus-CHECK, one statement.
124
- mirrors(on(Task.where({ kind: Kind.Deterministic }), "id"), on(DeterministicGrading, "task")),
125
- mirrors(on(Task.where({ kind: Kind.CustomOperator }), "id"), on(CustomOperatorGrading, "task"))
167
+ // exists WITH that kind — composite-FK-plus-CHECK, one statement. These
168
+ // mirrors are also what type `task` on both arms: each lands in the
169
+ // "Task.id" generator class.
170
+ mirrors(on(Task.where({ kind: "Deterministic" }), "id"), on(DeterministicGrading, "task")),
171
+ mirrors(on(Task.where({ kind: "CustomOperator" }), "id"), on(CustomOperatorGrading, "task"))
126
172
  // Exclusivity is a theorem, not a statement: one id in two arms would
127
173
  // force `kind` to equal two handles against the fresh key on id.
128
174
  // The executor spends the same theorem again — recipe 22's free lunch.
129
175
  ])
176
+
177
+ // Host dispatch over the discriminator is native `switch` narrowing over
178
+ // the handle union (`Infer<typeof Kind.id>` = "Deterministic" |
179
+ // "CustomOperator") — rows already arrive carrying the handle name, and
180
+ // `satisfies never` makes the switch exhaustive: a missing arm is a
181
+ // compile error.
182
+ const gradedBy = (kind: Infer<typeof Kind.id>) => {
183
+ switch (kind) {
184
+ case "Deterministic":
185
+ return "tolerance"
186
+ case "CustomOperator":
187
+ return "operator"
188
+ default:
189
+ return kind satisfies never
190
+ }
191
+ }
130
192
  ```
131
193
 
132
194
  ## 3. 0..1 optional attributes
@@ -140,10 +202,8 @@ No nulls, anywhere. Optional data is an absent fact in a child relation; the
140
202
  child's key plus a one-way containment *is* "nullable column", done honestly.
141
203
 
142
204
  ```ts
143
- const BusinessId = u64.as("BusinessId")
144
-
145
- const Business = relation("Business", { id: BusinessId.fresh, name: str })
146
- const MailingAddress = relation("MailingAddress", { business: BusinessId, line: str, city: str })
205
+ const Business = relation("Business", { id: u64.fresh, name: str })
206
+ const MailingAddress = relation("MailingAddress", { business: u64, line: str, city: str })
147
207
 
148
208
  const Optionality = schema("Optionality", { Business, MailingAddress }, [
149
209
  key(MailingAddress, ["business"]), // at most one address...
@@ -154,12 +214,13 @@ const Optionality = schema("Optionality", { Business, MailingAddress }, [
154
214
  ])
155
215
 
156
216
  // Negation is plain anti-join (no null branch exists in any operator):
157
- const unaddressed = query(Optionality).rule((r) =>
158
- r
159
- .match(Business, { id: r.var("b") })
160
- .where(r.not(MailingAddress, { business: r.var("b") }))
217
+ const unaddressed = query(Optionality).rule((r) => {
218
+ const { b } = r.vars("b")
219
+ return r
220
+ .match(Business, { id: b })
221
+ .where(not(MailingAddress, { business: b }))
161
222
  .select("b")
162
- )
223
+ })
163
224
  ```
164
225
 
165
226
  ## 4. Money
@@ -167,25 +228,21 @@ const unaddressed = query(Optionality).rule((r) =>
167
228
  Guarantee: host discipline + validator premises — fixed-point scale and
168
229
  currency grouping live in host domains; containments only resolve references.
169
230
 
170
- Fixed-point i64 minor units; the domain label owns scale and currency intent.
171
- Floats are permanently refused (the ledger); proration and FX are host
172
- arithmetic.
231
+ Fixed-point i64 minor units. Floats are permanently refused (the ledger);
232
+ proration and FX are host arithmetic. `minor` sits in no law bare, and bare
233
+ pairs only with bare (the class laws), so a stray join against some other
234
+ i64 column refuses unless a statement puts them in one class. Scale and
235
+ currency intent are host discipline; the closed `currency` reference is what
236
+ the schema CAN say, and says.
173
237
 
174
238
  ```ts
175
- const AccountId = u64.as("AccountId")
176
- const PostingId = u64.as("PostingId")
177
- // Minor units in i64 (±92 quadrillion cents); `.as("Minor")` is the domain
178
- // label — the schema type polices cross-domain confusion, not the engine
179
- // (hard structural typing, 10-data-model.md).
180
- const Minor = i64.as("Minor")
181
-
182
239
  const Currency = closed("Currency", ["Usd", "Eur", "Gbp"])
183
- const Account = relation("Account", { id: AccountId.fresh, name: str })
240
+ const Account = relation("Account", { id: u64.fresh, name: str })
184
241
  const Posting = relation("Posting", {
185
- id: PostingId.fresh,
186
- account: AccountId,
242
+ id: u64.fresh,
243
+ account: u64,
187
244
  currency: Currency.id,
188
- minor: Minor
245
+ minor: i64
189
246
  })
190
247
 
191
248
  const Money = schema("Money", { Currency, Account, Posting }, [
@@ -197,16 +254,10 @@ const Money = schema("Money", { Currency, Account, Posting }, [
197
254
  // Sum folds wide with one final range check, so totals cannot wrap
198
255
  // silently. Bind the fresh id: set semantics would collapse two equal
199
256
  // (account, currency, minor) postings without it.
200
- const totals = query(Money).rule((r) =>
201
- r
202
- .match(Posting, {
203
- id: r.var("id"),
204
- account: r.var("account"),
205
- currency: r.var("currency"),
206
- minor: r.var("minor")
207
- })
208
- .select("account", "currency", r.sum("minor"))
209
- )
257
+ const totals = query(Money).rule((r) => {
258
+ const { id, account, currency, minor } = r.vars("id", "account", "currency", "minor")
259
+ return r.match(Posting, { id, account, currency, minor }).select("account", "currency", r.sum("minor"))
260
+ })
210
261
  ```
211
262
 
212
263
  ## 5. Content addressing
@@ -218,19 +269,19 @@ The decision rule for byte-shaped data: **intern what repeats (`str`); inline
218
269
  what identifies (`bytes(n)`)**.
219
270
 
220
271
  ```ts
221
- const DocumentId = u64.as("DocumentId")
222
- const PayloadHash = bytes(32).as("PayloadHash")
223
-
224
272
  const Region = closed("Region", ["Us", "Eu"])
225
273
  const Document = relation("Document", {
226
- id: DocumentId.fresh,
274
+ id: u64.fresh,
227
275
  name: str, // repeats: interned, id-equality
228
- payload: PayloadHash // identifies: the blake3 of the external blob — inline, never interned
276
+ payload: bytes(32) // identifies: the blake3 of the external blob — inline, never interned
229
277
  })
230
- const Replica = relation("Replica", { payload: PayloadHash, region: Region.id })
278
+ const Replica = relation("Replica", { payload: bytes(32), region: Region.id })
231
279
 
232
280
  const Content = schema("Content", { Region, Document, Replica }, [
233
281
  key(Document, ["payload"]), // content-addressed: one doc per digest
282
+ // This containment is the digest columns' typing: neither is fresh, so
283
+ // the pair is a generator-less class named by its least member
284
+ // coordinate — "Document.payload" (the class laws' second rule).
234
285
  contained(on(Replica, "payload"), on(Document, "payload")),
235
286
  contained(on(Replica, "region"), on(Region, "id"))
236
287
  // bytes(n) is identity-only (Eq/Ne, membership): a digest's lexicographic
@@ -240,7 +291,10 @@ const Content = schema("Content", { Region, Document, Replica }, [
240
291
  ])
241
292
 
242
293
  // a bytes param self-encodes (Uint8Array by inference):
243
- const byDigest = query(Content).rule((r) => r.match(Document, { id: r.var("id"), payload: r.param("digest") }).select("id"))
294
+ const byDigest = query(Content).rule((r) => {
295
+ const { id } = r.vars("id")
296
+ return r.match(Document, { id, payload: r.param("digest") }).select("id")
297
+ })
244
298
  ```
245
299
 
246
300
  ## Vocabularies
@@ -255,22 +309,22 @@ declared priority handles.
255
309
  The enum idiom's replacement, first-class: a vocabulary is a **closed
256
310
  relation** — its ground axioms are declared in the schema, sealed at
257
311
  validate, frozen by the fingerprint, virtual in storage. The store holds zero
258
- vocabulary bytes, and handles are the literals on every surface.
312
+ vocabulary bytes, and handle names are the string literals on every surface.
259
313
 
260
314
  ```ts
261
- const TicketId = u64.as("TicketId")
262
-
263
- // Tier 1: handles only. `closed()` mints one bare-bigint constant per handle
264
- // (ids = declaration order) an emission, not a type: the engine's
265
- // vocabulary stays relational; the host matches on `Priority.Urgent`.
315
+ // Tier 1: handles only. At the host surface a handle is its NAME — a string
316
+ // literal of the roster's union ("Low" | "Normal" | "Urgent"); the engine's
317
+ // vocabulary stays relational (ids = declaration order) and the marshal
318
+ // owns the bijection. Dispatch is native `switch` narrowing (recipe 2).
266
319
  const Priority = closed("Priority", ["Low", "Normal", "Urgent"])
267
320
 
268
- const Ticket = relation("Ticket", { id: TicketId.fresh, priority: Priority.id, opened_at: i64 })
321
+ const Ticket = relation("Ticket", { id: u64.fresh, priority: Priority.id, opened_at: i64 })
269
322
 
270
323
  const Tickets = schema("Tickets", { Priority, Ticket }, [
271
- // A closed reference is an ordinary u64 under the handle domain plus one
272
- // containment; the judgment compiles at validate to a member-set test —
273
- // one AND, one bit test, no probe (30-dependencies.md).
324
+ // A closed reference is an ordinary u64 under one containment (which also
325
+ // types `priority` into the "Priority.id" generator class); the judgment
326
+ // compiles at validate to a member-set test — one AND, one bit test, no
327
+ // probe (30-dependencies.md).
274
328
  contained(on(Ticket, "priority"), on(Priority, "id"))
275
329
  ])
276
330
 
@@ -279,103 +333,130 @@ const Tickets = schema("Tickets", { Priority, Ticket }, [
279
333
  // law: intrinsic meaning goes here (changing it is a new theory); policy
280
334
  // that drifts without a rebuild is an ordinary relation — a vocabulary is
281
335
  // never written, only declared.
282
- const urgent = query(Tickets).rule((r) => r.match(Ticket, { id: r.var("t"), priority: Priority.Urgent }).select("t"))
336
+ const urgent = query(Tickets).rule((r) => {
337
+ const { t } = r.vars("t")
338
+ return r.match(Ticket, { id: t, priority: "Urgent" }).select("t")
339
+ })
340
+
341
+ // Set membership is a plain array — the drizzle law's spelling, closed-only
342
+ // in query match records (an ordinary u64/str field's membership is a bound
343
+ // ∈-set param, `r.inSet`); the array folds to the same wire set the param
344
+ // spelling crosses. In `.where()` selections arrays work at EVERY field kind.
345
+ const actionable = query(Tickets).rule((r) => {
346
+ const { t } = r.vars("t")
347
+ return r.match(Ticket, { id: t, priority: ["Normal", "Urgent"] }).select("t")
348
+ })
283
349
  ```
284
350
 
285
351
  ## 7. The classification
286
352
 
287
- Guarantee: validator/runtime premise — closed payload facts and the compiled
288
- member-set restriction confine certificates to mastered handles.
353
+ Guarantee: validator/runtime premise — closed payload facts and the
354
+ ψ-selected containment restrict certificates to the compiled
355
+ mastered-handle set.
289
356
 
290
357
  The fused form: the vocabulary carries its intrinsic facts as **payload
291
- columns** — one ground axiom per handle, values sealed with the schema.
292
- Axioms are declared, never written.
293
-
294
- > **SDK surface note.** The engine spells the restriction as a ψ-selected
295
- > containment over the vocabulary itself — `Certificate(kind) <= Kind(id |
296
- > mastered == true)` — and its reads walk the payload in the query (`Kind(id:
297
- > k, mastered == true)`). Neither face is writable in this SDK today: a
298
- > `closed()` value carries no `.where`, and query atoms match ordinary
299
- > relations only (a reported surface finding). Both spellings below are
300
- > engine-equivalent **because the roster is sealed**: the host folds the
301
- > payload over the sealed axioms at schema-build time — the same member set
302
- > the engine's ψ would compile, {DirectPass, JudgedPass} against complement
303
- > {Failed} — and the exclusion window enforces the identical commit judgment.
358
+ columns** — one ground axiom per handle, values sealed with the schema, read
359
+ by ψ-selections. Axioms are declared, never written.
304
360
 
305
361
  ```ts
306
- const AttemptId = u64.as("AttemptId")
307
-
308
362
  // Tier 2: payload columns state what each word MEANS, next to the word.
309
363
  // A rubric change is a new theory — exactly right for meaning.
310
- const Kind = closed("Kind", { mastered: bool, rank: u64 })({
311
- DirectPass: { mastered: true, rank: 30n },
312
- JudgedPass: { mastered: true, rank: 20n },
313
- Failed: { mastered: false, rank: 10n }
314
- })
315
- const Attempt = relation("Attempt", { id: AttemptId.fresh, kind: Kind.id })
316
- const Certificate = relation("Certificate", { attempt: AttemptId, kind: Kind.id })
364
+ const Kind = closed(
365
+ "Kind",
366
+ { mastered: bool, rank: u64 },
367
+ {
368
+ DirectPass: { mastered: true, rank: 30n },
369
+ JudgedPass: { mastered: true, rank: 20n },
370
+ Failed: { mastered: false, rank: 10n }
371
+ }
372
+ )
373
+ const Attempt = relation("Attempt", { id: u64.fresh, kind: Kind.id })
374
+ const Certificate = relation("Certificate", { attempt: u64, kind: Kind.id })
317
375
 
318
376
  const Review = schema("Review", { Kind, Attempt, Certificate }, [
319
377
  contained(on(Attempt, "kind"), on(Kind, "id")),
320
378
  key(Certificate, ["attempt"]),
321
379
  contained(on(Certificate, "attempt"), on(Attempt, "id")),
322
- contained(on(Certificate, "kind"), on(Kind, "id")),
323
- // Certificates carry mastered kinds only, spelled over the sealed
324
- // roster's complement: per attempt, ZERO certificates at a non-mastered
325
- // kind — O(1) at commit, judged on every touching commit.
326
- window(on(Attempt, "id"), none, on(Certificate.where({ kind: Kind.Failed }), "attempt"))
380
+ // ψ reads the payload: certificates carry mastered kinds only — the
381
+ // member set {DirectPass, JudgedPass} compiles at validate and the
382
+ // judgment is O(1) at commit (recipe 8 is this statement's own recipe).
383
+ contained(on(Certificate, "kind"), on(Kind.where({ mastered: true }), "id"))
327
384
  ])
328
385
 
329
- // The classification read duplicates no flag onto Attempt — the payload is
330
- // read off the sealed axioms (`Kind.axioms.DirectPass.mastered`), and the
331
- // member set is spelled as a rule union (recipe 22's shape; rules selecting
332
- // different handles are provably disjoint — the free lunch):
333
- const masteredAttempts = query(Review)
334
- .rule((r) => r.match(Attempt, { id: r.var("a"), kind: Kind.DirectPass }).select("a"))
335
- .rule((r) => r.match(Attempt, { id: r.var("a"), kind: Kind.JudgedPass }).select("a"))
386
+ // The classification read duplicates no flag onto Attempt — ψ walks the
387
+ // vocabulary's payload in the query too: a closed relation is matchable
388
+ // exactly like an ordinary one, and the atom folds at prepare into a
389
+ // plan-constant handle set on its sibling.
390
+ const masteredAttempts = query(Review).rule((r) => {
391
+ const { a, k } = r.vars("a", "k")
392
+ return r
393
+ .match(Attempt, { id: a, kind: k })
394
+ .match(Kind, { id: k, mastered: true })
395
+ .select("a")
396
+ })
397
+
398
+ // Host dispatch on the payload tier is the record-table idiom — a `Record`
399
+ // over the handle union is total by type (a missing or extra entry is a
400
+ // compile error), and each entry reads its sealed axiom row off the typed
401
+ // `Kind.axioms` readback:
402
+ const labels: Record<Infer<typeof Kind.id>, string> = {
403
+ DirectPass: `mastered, rank ${Kind.axioms.DirectPass.rank}`,
404
+ JudgedPass: `mastered, rank ${Kind.axioms.JudgedPass.rank}`,
405
+ Failed: "not mastered"
406
+ }
407
+ const label = (k: Infer<typeof Kind.id>) => labels[k]
336
408
  ```
337
409
 
410
+ Two honest boundaries. The fold has limits: payload escaping to the head and
411
+ param-bearing filters don't fold at prepare — the engine falls back to a
412
+ virtual-image join over the sealed extension, and the semantics are identical
413
+ either way. And for an ALREADY-DEPLOYED store, moving from the old
414
+ complement-window workaround to these ψ spellings is a NEW theory — a
415
+ different fingerprint — which is recipe 28's ETL territory: humans own that
416
+ migration.
417
+
338
418
  ## 8. The sub-vocabulary
339
419
 
340
- Guarantee: validator/runtime premise — the member set compiled from the
341
- sealed extension confines escalations to paging severities; a nonmember
342
- write is commit-rejected.
420
+ Guarantee: validator/runtime premise — ψ over the sealed extension compiles
421
+ the exact paging member set; a nonmember write is commit-rejected.
343
422
 
344
- A reference constrained to the facts of a vocabulary that satisfy a payload
345
- selection. Because the target is closed and sealed, the enforcement plan is
346
- not a probe strategy — it is **the answer set itself**, fixed when the schema
347
- is built. (The engine's direct ψ spelling — `Escalation(severity) <=
348
- Severity(id | pages == true)` — is not yet writable here; see recipe 7's
349
- surface note. The complement exclusion below compiles to the same commit
350
- judgment over the same sealed roster.)
423
+ The ψ-selected containment: a reference constrained to the facts of a
424
+ vocabulary that satisfy a payload selection. Because the target is closed and
425
+ sealed, the enforcement plan is not a probe strategy — it is **the answer set
426
+ itself**, fixed when the schema is built.
351
427
 
352
428
  ```ts
353
- const IncidentId = u64.as("IncidentId")
354
-
355
- const Severity = closed("Severity", { pages: bool })({
356
- Info: { pages: false },
357
- Warning: { pages: false },
358
- Critical: { pages: true },
359
- Fatal: { pages: true }
360
- })
361
- const Incident = relation("Incident", { id: IncidentId.fresh, severity: Severity.id })
362
- const Escalation = relation("Escalation", { incident: IncidentId, severity: Severity.id, at: i64 })
429
+ const Severity = closed(
430
+ "Severity",
431
+ { pages: bool },
432
+ {
433
+ Info: { pages: false },
434
+ Warning: { pages: false },
435
+ Critical: { pages: true },
436
+ Fatal: { pages: true }
437
+ }
438
+ )
439
+ const Incident = relation("Incident", { id: u64.fresh, severity: Severity.id })
440
+ const Escalation = relation("Escalation", { incident: u64, severity: Severity.id, at: i64 })
363
441
 
364
442
  const Oncall = schema("Oncall", { Severity, Incident, Escalation }, [
365
443
  contained(on(Incident, "severity"), on(Severity, "id")),
366
444
  contained(on(Escalation, "incident"), on(Incident, "id")),
367
- contained(on(Escalation, "severity"), on(Severity, "id")),
368
445
  // The sub-vocabulary: an escalation carries a PAGING severity, by
369
- // statement per incident, ZERO escalations at a non-paging severity
370
- // (every escalation resolves its incident, so every escalation is
371
- // judged). An escalation at Severity.Info aborts the commit.
372
- window(on(Incident, "id"), none, on(Escalation.where({ severity: oneOf(Severity.Info, Severity.Warning) }), "incident"))
446
+ // statement. ψ over the sealed extension compiles to the member set
447
+ // {Critical, Fatal}; the judgment is one bit test per touched fact,
448
+ // and an escalation at "Info" aborts the commit.
449
+ contained(on(Escalation, "severity"), on(Severity.where({ pages: true }), "id"))
373
450
  ])
374
451
 
375
- // who is being paged (the same member set, on the read side):
376
- const paged = query(Oncall)
377
- .rule((r) => r.match(Escalation, { incident: r.var("i"), severity: Severity.Critical }).select("i"))
378
- .rule((r) => r.match(Escalation, { incident: r.var("i"), severity: Severity.Fatal }).select("i"))
452
+ // who is being paged the same ψ, on the read side:
453
+ const paged = query(Oncall).rule((r) => {
454
+ const { i, s } = r.vars("i", "s")
455
+ return r
456
+ .match(Escalation, { incident: i, severity: s })
457
+ .match(Severity, { id: s, pages: true })
458
+ .select("i")
459
+ })
379
460
  ```
380
461
 
381
462
  ## Structure
@@ -397,14 +478,12 @@ nonemptiness), and the unit-slot sidecar (`interval(u64, 1n)` — the width is
397
478
  the type: a wrong-width value is unrepresentable).
398
479
 
399
480
  ```ts
400
- const PlaylistId = u64.as("PlaylistId")
401
-
402
- const Playlist = relation("Playlist", { id: PlaylistId.fresh, name: str })
481
+ const Playlist = relation("Playlist", { id: u64.fresh, name: str })
403
482
  // The extent: a 0..1 child, because empty playlists exist and empty
404
483
  // intervals do not — presence of the child IS nonemptiness.
405
- const Extent = relation("Extent", { playlist: PlaylistId, span: interval(u64) })
484
+ const Extent = relation("Extent", { playlist: u64, span: interval(u64) })
406
485
  // The unit slot: position p occupies [p, p+1) — the width is the type.
407
- const Slot = relation("Slot", { playlist: PlaylistId, slot: interval(u64, 1n), track: str })
486
+ const Slot = relation("Slot", { playlist: u64, slot: interval(u64, 1n), track: str })
408
487
 
409
488
  const Playlists = schema("Playlists", { Playlist, Extent, Slot }, [
410
489
  contained(on(Extent, "playlist"), on(Playlist, "id")),
@@ -416,12 +495,13 @@ const Playlists = schema("Playlists", { Playlist, Extent, Slot }, [
416
495
  ])
417
496
 
418
497
  // Positional access is membership — "what plays at position ?pos":
419
- const playingAt = query(Playlists).rule((r) =>
420
- r
421
- .match(Slot, { playlist: r.param("list"), slot: r.var("s"), track: r.var("track") })
422
- .where(r.pointIn(r.param("pos"), r.var("s")))
498
+ const playingAt = query(Playlists).rule((r) => {
499
+ const { slot, track } = r.vars("slot", "track")
500
+ return r
501
+ .match(Slot, { playlist: r.param("list"), slot, track })
502
+ .where(pointIn(r.param("pos"), slot))
423
503
  .select("track")
424
- )
504
+ })
425
505
  ```
426
506
 
427
507
  Middle insert is honest about its cost: making room at position `k` shifts
@@ -441,22 +521,22 @@ Node header + per-kind arms (recipe 2's pattern); every edge resolves; the
441
521
  shape theorems come from keys on the edge relations.
442
522
 
443
523
  ```ts
444
- const NodeId = u64.as("NodeId")
445
-
446
524
  const Kind = closed("Kind", ["Lit", "Add"])
447
- const Node = relation("Node", { id: NodeId.fresh, kind: Kind.id })
448
- const Lit = relation("Lit", { node: NodeId, value: i64 })
449
- const Add = relation("Add", { node: NodeId, lhs: NodeId, rhs: NodeId })
450
- const Parent = relation("Parent", { child: NodeId, parent: NodeId })
525
+ const Node = relation("Node", { id: u64.fresh, kind: Kind.id })
526
+ const Lit = relation("Lit", { node: u64, value: i64 })
527
+ const Add = relation("Add", { node: u64, lhs: u64, rhs: u64 })
528
+ const Parent = relation("Parent", { child: u64, parent: u64 })
451
529
 
452
530
  const Ast = schema("Ast", { Kind, Node, Lit, Add, Parent }, [
453
531
  contained(on(Node, "kind"), on(Kind, "id")),
454
532
  key(Lit, ["node"]),
455
533
  key(Add, ["node"]),
456
534
  // Every node's arm is total, valid, and exclusive (recipe 2's theorems):
457
- mirrors(on(Node.where({ kind: Kind.Lit }), "id"), on(Lit, "node")),
458
- mirrors(on(Node.where({ kind: Kind.Add }), "id"), on(Add, "node")),
459
- // Every child edge resolves — no dangling subtrees, judged at commit:
535
+ mirrors(on(Node.where({ kind: "Lit" }), "id"), on(Lit, "node")),
536
+ mirrors(on(Node.where({ kind: "Add" }), "id"), on(Add, "node")),
537
+ // Every child edge resolves — no dangling subtrees, judged at commit
538
+ // (these containments also put lhs/rhs in the "Node.id" class, which is
539
+ // exactly what lets the query below join lhs against Lit.node):
460
540
  contained(on(Add, "lhs"), on(Node, "id")),
461
541
  contained(on(Add, "rhs"), on(Node, "id")),
462
542
  // Functional parent (one parent per child) ⇒ the reachable shape is
@@ -467,12 +547,13 @@ const Ast = schema("Ast", { Kind, Node, Lit, Add, Parent }, [
467
547
  contained(on(Parent, "parent"), on(Node, "id"))
468
548
  ])
469
549
 
470
- const lhsLiteral = query(Ast).rule((r) =>
471
- r
472
- .match(Add, { node: r.param("n"), lhs: r.var("l") })
473
- .match(Lit, { node: r.var("l"), value: r.var("v") })
550
+ const lhsLiteral = query(Ast).rule((r) => {
551
+ const { l, v } = r.vars("l", "v")
552
+ return r
553
+ .match(Add, { node: r.param("n"), lhs: l })
554
+ .match(Lit, { node: l, value: v })
474
555
  .select("v")
475
- )
556
+ })
476
557
  ```
477
558
 
478
559
  ## 11. Typed graphs
@@ -484,13 +565,10 @@ One relation per edge kind: endpoint containments pin which node kinds each
484
565
  edge may touch.
485
566
 
486
567
  ```ts
487
- const PersonId = u64.as("PersonId")
488
- const RepoId = u64.as("RepoId")
489
-
490
- const Person = relation("Person", { id: PersonId.fresh, name: str })
491
- const Repo = relation("Repo", { id: RepoId.fresh, name: str })
492
- const Follows = relation("Follows", { follower: PersonId, followee: PersonId })
493
- const Maintains = relation("Maintains", { person: PersonId, repo: RepoId })
568
+ const Person = relation("Person", { id: u64.fresh, name: str })
569
+ const Repo = relation("Repo", { id: u64.fresh, name: str })
570
+ const Follows = relation("Follows", { follower: u64, followee: u64 })
571
+ const Maintains = relation("Maintains", { person: u64, repo: u64 })
494
572
 
495
573
  const Graph = schema("Graph", { Person, Repo, Follows, Maintains }, [
496
574
  contained(on(Follows, "follower"), on(Person, "id")), // a Person→Person edge, by statement —
@@ -501,15 +579,17 @@ const Graph = schema("Graph", { Person, Repo, Follows, Maintains }, [
501
579
  key(Maintains, ["person", "repo"])
502
580
  ])
503
581
 
504
- // Mutual follows — joins are explicit var reuse on both ends; `lt` keeps
505
- // each pair once:
506
- const mutual = query(Graph).rule((r) =>
507
- r
508
- .match(Follows, { follower: r.var("a"), followee: r.var("b") })
509
- .match(Follows, { follower: r.var("b"), followee: r.var("a") })
510
- .where(r.lt(r.var("a"), r.var("b")))
582
+ // Mutual follows — joins are explicit var reuse on both ends (both columns
583
+ // live in the "Person.id" class, so the reuse is lawful); `lt` keeps each
584
+ // pair once:
585
+ const mutual = query(Graph).rule((r) => {
586
+ const { a, b } = r.vars("a", "b")
587
+ return r
588
+ .match(Follows, { follower: a, followee: b })
589
+ .match(Follows, { follower: b, followee: a })
590
+ .where(lt(a, b))
511
591
  .select("a", "b")
512
- )
592
+ })
513
593
  ```
514
594
 
515
595
  ## 12. Entity-component
@@ -522,12 +602,10 @@ entity has a component iff the fact exists; a new component kind is a new
522
602
  relation, not a wider fact.
523
603
 
524
604
  ```ts
525
- const EntityId = u64.as("EntityId")
526
-
527
- const Entity = relation("Entity", { id: EntityId.fresh, name: str })
528
- const Transform = relation("Transform", { entity: EntityId, x: i64, y: i64 })
529
- const Velocity = relation("Velocity", { entity: EntityId, dx: i64, dy: i64 })
530
- const Renderable = relation("Renderable", { entity: EntityId, mesh: str })
605
+ const Entity = relation("Entity", { id: u64.fresh, name: str })
606
+ const Transform = relation("Transform", { entity: u64, x: i64, y: i64 })
607
+ const Velocity = relation("Velocity", { entity: u64, dx: i64, dy: i64 })
608
+ const Renderable = relation("Renderable", { entity: u64, mesh: str })
531
609
 
532
610
  const Ecs = schema("Ecs", { Entity, Transform, Velocity, Renderable }, [
533
611
  key(Transform, ["entity"]), // each component 0..1 per entity
@@ -536,17 +614,19 @@ const Ecs = schema("Ecs", { Entity, Transform, Velocity, Renderable }, [
536
614
  contained(on(Velocity, "entity"), on(Entity, "id")),
537
615
  key(Renderable, ["entity"]),
538
616
  // An archetype rule is one containment: every Renderable has a Transform
539
- // (and, through it, an Entity — containment composes).
617
+ // (and, through it, an Entity — containment composes, and the class
618
+ // composes with it: every `entity` column lands in "Entity.id").
540
619
  contained(on(Renderable, "entity"), on(Transform, "entity"))
541
620
  ])
542
621
 
543
622
  // The physics join is the component intersection:
544
- const physics = query(Ecs).rule((r) =>
545
- r
546
- .match(Transform, { entity: r.var("e"), x: r.var("x"), y: r.var("y") })
547
- .match(Velocity, { entity: r.var("e"), dx: r.var("dx"), dy: r.var("dy") })
548
- .select("e", "x", "y", "dx", "dy")
549
- )
623
+ const physics = query(Ecs).rule((r) => {
624
+ const { entity, x, y, dx, dy } = r.vars("entity", "x", "y", "dx", "dy")
625
+ return r
626
+ .match(Transform, { entity, x, y })
627
+ .match(Velocity, { entity, dx, dy })
628
+ .select("entity", "x", "y", "dx", "dy")
629
+ })
550
630
  ```
551
631
 
552
632
  ## 13. State machines
@@ -560,12 +640,10 @@ conditional reference target — a reference to "an order *that is shipped*" —
560
640
  is one selected statement, the statement SQL cannot write.
561
641
 
562
642
  ```ts
563
- const OrderId = u64.as("OrderId")
564
-
565
643
  const State = closed("State", ["Cart", "Placed", "Shipped"])
566
- const Order = relation("Order", { id: OrderId.fresh, state: State.id })
567
- const Placement = relation("Placement", { order: OrderId, at: i64 })
568
- const Shipment = relation("Shipment", { order: OrderId, carrier: str, at: i64 })
644
+ const Order = relation("Order", { id: u64.fresh, state: State.id })
645
+ const Placement = relation("Placement", { order: u64, at: i64 })
646
+ const Shipment = relation("Shipment", { order: u64, carrier: str, at: i64 })
569
647
 
570
648
  const Orders = schema("Orders", { State, Order, Placement, Shipment }, [
571
649
  contained(on(Order, "state"), on(State, "id")),
@@ -577,17 +655,18 @@ const Orders = schema("Orders", { State, Order, Placement, Shipment }, [
577
655
  // The conditional target, both ways: every Shipment references an order
578
656
  // THAT IS Shipped (validity), and every Shipped order has its Shipment
579
657
  // (totality) — the transition and its evidence commit together.
580
- mirrors(on(Shipment, "order"), on(Order.where({ state: State.Shipped }), "id"))
658
+ mirrors(on(Shipment, "order"), on(Order.where({ state: "Shipped" }), "id"))
581
659
  // Transition predicates ("only Placed may ship") are host code under the
582
660
  // generation witness — recipe 20; the schema pins the states, not the paths.
583
661
  ])
584
662
 
585
- const shipped = query(Orders).rule((r) =>
586
- r
587
- .match(Order, { id: r.var("id"), state: State.Shipped })
588
- .match(Shipment, { order: r.var("id"), carrier: r.var("carrier") })
663
+ const shipped = query(Orders).rule((r) => {
664
+ const { id, carrier } = r.vars("id", "carrier")
665
+ return r
666
+ .match(Order, { id, state: "Shipped" })
667
+ .match(Shipment, { order: id, carrier })
589
668
  .select("id", "carrier")
590
- )
669
+ })
591
670
  ```
592
671
 
593
672
  ## Time and coverage
@@ -600,40 +679,33 @@ keyed_eq_unique_correspondence`), while pointwise keys/coverage enforce only
600
679
  declared hard policy.
601
680
 
602
681
  Policy as schema: hard rules are pointwise keys, soft rules are the statements
603
- you decline to write.
604
-
605
- > **SDK surface note.** `Claim.source` carries the accepted attendance's id,
606
- > so it is labeled `.as("AttendanceId")` here: the SDK's domain wall pairs
607
- > faces by label, where the engine cookbook's bare `source: u64` relied on
608
- > host discipline. The label lives in the schema type only — it never touches
609
- > the value or the fingerprint.
682
+ you decline to write. `Claim.source` carries the accepted attendance's id and
683
+ declares nothing about it — the selected `mirrors` law below is what TYPES
684
+ it: pairing `source` with `Attendance.id` puts it in the `"Attendance.id"`
685
+ generator class. The class flows from the statement; no link declaration
686
+ exists or is needed.
610
687
 
611
688
  ```ts
612
- const PersonId = u64.as("PersonId")
613
- const RoomId = u64.as("RoomId")
614
- const EventId = u64.as("EventId")
615
- const AttendanceId = u64.as("AttendanceId")
616
-
617
689
  const Rsvp = closed("Rsvp", ["Accepted", "Tentative", "Declined"])
618
690
  const Arm = closed("Arm", ["Busy", "Ooo"])
619
691
 
620
- const Person = relation("Person", { id: PersonId.fresh, name: str })
621
- const Room = relation("Room", { id: RoomId.fresh, name: str })
622
- const Event = relation("Event", { id: EventId.fresh, span: interval(i64) })
692
+ const Person = relation("Person", { id: u64.fresh, name: str })
693
+ const Room = relation("Room", { id: u64.fresh, name: str })
694
+ const Event = relation("Event", { id: u64.fresh, span: interval(i64) })
623
695
  const Attendance = relation("Attendance", {
624
- id: AttendanceId.fresh,
625
- event: EventId,
626
- person: PersonId,
696
+ id: u64.fresh,
697
+ event: u64,
698
+ person: u64,
627
699
  rsvp: Rsvp.id
628
700
  })
629
701
  const Claim = relation("Claim", {
630
- source: u64.as("AttendanceId"),
631
- person: PersonId,
702
+ source: u64,
703
+ person: u64,
632
704
  arm: Arm.id,
633
705
  span: interval(i64)
634
706
  })
635
- const Booking = relation("Booking", { room: RoomId, event: EventId, span: interval(i64) })
636
- const WorkHours = relation("WorkHours", { person: PersonId, hours: interval(i64) })
707
+ const Booking = relation("Booking", { room: u64, event: u64, span: interval(i64) })
708
+ const WorkHours = relation("WorkHours", { person: u64, hours: interval(i64) })
637
709
 
638
710
  const Calendar = schema("Calendar", { Rsvp, Arm, Person, Room, Event, Attendance, Claim, Booking, WorkHours }, [
639
711
  contained(on(Attendance, "event"), on(Event, "id")),
@@ -647,28 +719,31 @@ const Calendar = schema("Calendar", { Rsvp, Arm, Person, Room, Event, Attendance
647
719
  key(Booking, ["room", "span"]),
648
720
  // SOFT: people CAN double-book — key(Claim, ["person", "span"]) is simply
649
721
  // not declared. Policy is the presence or absence of one statement.
650
- // Accepting an invitation IS claiming the time (totality + validity):
651
- mirrors(on(Attendance.where({ rsvp: Rsvp.Accepted }), "id"), on(Claim.where({ arm: Arm.Busy }), "source")),
722
+ // Accepting an invitation IS claiming the time (totality + validity)
723
+ // and this is the statement that types Claim.source:
724
+ mirrors(on(Attendance.where({ rsvp: "Accepted" }), "id"), on(Claim.where({ arm: "Busy" }), "source")),
652
725
  // Busy time lies inside working hours, pointwise — coverage rides the
653
726
  // target's own key (disjoint + ordered is a theorem, not a request):
654
727
  key(WorkHours, ["person", "hours"]),
655
- contained(on(Claim.where({ arm: Arm.Busy }), ["person", "span"]), on(WorkHours, ["person", "hours"])),
728
+ contained(on(Claim.where({ arm: "Busy" }), ["person", "span"]), on(WorkHours, ["person", "hours"])),
656
729
  contained(on(Booking, "room"), on(Room, "id")),
657
730
  contained(on(Booking, "event"), on(Event, "id"))
658
731
  ])
659
732
 
660
- const roomConflicts = query(Calendar).rule((r) =>
661
- r
662
- .match(Booking, { room: r.var("room"), span: r.var("s") })
663
- .where(r.allen(r.var("s"), ALLEN.intersects, r.param("want")))
664
- .select("room", "s")
665
- )
666
- const personLoad = query(Calendar).rule((r) =>
667
- r
668
- .match(Claim, { person: r.var("person"), span: r.var("s") })
669
- .where(r.allen(r.var("s"), ALLEN.intersects, r.param("window")))
670
- .select("person", "s")
671
- )
733
+ const roomConflicts = query(Calendar).rule((r) => {
734
+ const { room, span } = r.vars("room", "span")
735
+ return r
736
+ .match(Booking, { room, span })
737
+ .where(allen(span, ALLEN.intersects, r.param("want")))
738
+ .select("room", "span")
739
+ })
740
+ const personLoad = query(Calendar).rule((r) => {
741
+ const { person, span } = r.vars("person", "span")
742
+ return r
743
+ .match(Claim, { person, span })
744
+ .where(allen(span, ALLEN.intersects, r.param("window")))
745
+ .select("person", "span")
746
+ })
672
747
  ```
673
748
 
674
749
  ## 15. Effective-dated configuration
@@ -684,10 +759,8 @@ lifetime (one-way coverage; version overhang remains legal), and "in force on
684
759
  date t" is one membership probe.
685
760
 
686
761
  ```ts
687
- const PolicyId = u64.as("PolicyId")
688
-
689
- const Policy = relation("Policy", { id: PolicyId.fresh, live: interval(i64) })
690
- const Version = relation("Version", { policy: PolicyId, rate_bps: i64, valid: interval(i64) })
762
+ const Policy = relation("Policy", { id: u64.fresh, live: interval(i64) })
763
+ const Version = relation("Version", { policy: u64, rate_bps: i64, valid: interval(i64) })
691
764
 
692
765
  const Pricing = schema("Pricing", { Policy, Version }, [
693
766
  contained(on(Version, "policy"), on(Policy, "id")),
@@ -700,20 +773,22 @@ const Pricing = schema("Pricing", { Policy, Version }, [
700
773
  ])
701
774
 
702
775
  // in force on date t — one membership probe:
703
- const inForce = query(Pricing).rule((r) =>
704
- r
705
- .match(Version, { policy: r.param("p"), rate_bps: r.var("rate_bps"), valid: r.var("v") })
706
- .where(r.pointIn(r.param("t"), r.var("v")))
776
+ const inForce = query(Pricing).rule((r) => {
777
+ const { rate_bps, valid } = r.vars("rate_bps", "valid")
778
+ return r
779
+ .match(Version, { policy: r.param("p"), rate_bps, valid })
780
+ .where(pointIn(r.param("t"), valid))
707
781
  .select("rate_bps")
708
- )
782
+ })
709
783
  // clean successions (half-open makes MEETS exact, no ±1 fudge):
710
- const successions = query(Pricing).rule((r) =>
711
- r
712
- .match(Version, { policy: r.var("p"), valid: r.var("a") })
713
- .match(Version, { policy: r.var("p"), valid: r.var("b") })
714
- .where(r.allen(r.var("a"), ALLEN.meets, r.var("b")))
784
+ const successions = query(Pricing).rule((r) => {
785
+ const { p, a, b } = r.vars("p", "a", "b")
786
+ return r
787
+ .match(Version, { policy: p, valid: a })
788
+ .match(Version, { policy: p, valid: b })
789
+ .where(allen(a, ALLEN.meets, b))
715
790
  .select("a", "b")
716
- )
791
+ })
717
792
  ```
718
793
 
719
794
  ## 16. Disjoint covers
@@ -728,10 +803,8 @@ coverage is a **disjoint cover** — no overlaps among pay periods and no holes
728
803
  in the fiscal year's source span. Pay periods may extend beyond that span.
729
804
 
730
805
  ```ts
731
- const FiscalYearId = u64.as("FiscalYearId")
732
-
733
- const FiscalYear = relation("FiscalYear", { id: FiscalYearId.fresh, span: interval(i64) })
734
- const PayPeriod = relation("PayPeriod", { year: FiscalYearId, seq: u64, span: interval(i64) })
806
+ const FiscalYear = relation("FiscalYear", { id: u64.fresh, span: interval(i64) })
807
+ const PayPeriod = relation("PayPeriod", { year: u64, seq: u64, span: interval(i64) })
735
808
 
736
809
  const Payroll = schema("Payroll", { FiscalYear, PayPeriod }, [
737
810
  contained(on(PayPeriod, "year"), on(FiscalYear, "id")),
@@ -742,12 +815,13 @@ const Payroll = schema("Payroll", { FiscalYear, PayPeriod }, [
742
815
  ])
743
816
 
744
817
  // the period holding date t:
745
- const holding = query(Payroll).rule((r) =>
746
- r
747
- .match(PayPeriod, { year: r.param("y"), seq: r.var("seq"), span: r.var("s") })
748
- .where(r.pointIn(r.param("t"), r.var("s")))
818
+ const holding = query(Payroll).rule((r) => {
819
+ const { seq, span } = r.vars("seq", "span")
820
+ return r
821
+ .match(PayPeriod, { year: r.param("y"), seq, span })
822
+ .where(pointIn(r.param("t"), span))
749
823
  .select("seq")
750
- )
824
+ })
751
825
  ```
752
826
 
753
827
  ## 17. Federal income tax
@@ -759,16 +833,14 @@ Brackets are intervals over money; the top bracket is a ray; regimes key on
759
833
  (year, status); and proration happens at write time, never at query time.
760
834
 
761
835
  ```ts
762
- const RegimeId = u64.as("RegimeId")
763
-
764
836
  const Status = closed("Status", ["Single", "MarriedJoint", "HeadOfHousehold"])
765
- const Regime = relation("Regime", { id: RegimeId.fresh, year: i64, status: Status.id })
766
- const Bracket = relation("Bracket", { regime: RegimeId, income: interval(i64), rate_bps: i64 })
837
+ const Regime = relation("Regime", { id: u64.fresh, year: i64, status: Status.id })
838
+ const Bracket = relation("Bracket", { regime: u64, income: interval(i64), rate_bps: i64 })
767
839
  const Residency = relation("Residency", { person: u64, span: interval(i64) })
768
840
  // Split at write: an Earned fact never spans a year boundary — writers
769
841
  // split (prorate) at the boundary, so no reader ever clips. The
770
842
  // representation move that deletes clip-at-query (gravestone, recipe 23).
771
- const Earned = relation("Earned", { person: u64, regime: RegimeId, span: interval(i64), minor: i64 })
843
+ const Earned = relation("Earned", { person: u64, regime: u64, span: interval(i64), minor: i64 })
772
844
 
773
845
  const Tax = schema("Tax", { Status, Regime, Bracket, Residency, Earned }, [
774
846
  contained(on(Regime, "status"), on(Status, "id")),
@@ -782,20 +854,23 @@ const Tax = schema("Tax", { Status, Regime, Bracket, Residency, Earned }, [
782
854
  contained(on(Earned, "regime"), on(Regime, "id")),
783
855
  key(Residency, ["person", "span"]),
784
856
  // Residency exclusion: income counts only where earned inside a residency
785
- // period — pointwise coverage, the same judgment as recipe 15's.
857
+ // period — pointwise coverage, the same judgment as recipe 15's. This
858
+ // pair statement is also what puts the two bare `person` columns in one
859
+ // (generator-less) class: "Residency.person", by least coordinate.
786
860
  contained(on(Earned, ["person", "span"]), on(Residency, ["person", "span"]))
787
861
  ])
788
862
 
789
863
  // the marginal bracket (membership probes the disjoint bracket set). Tax
790
864
  // owed is host arithmetic over the bracket walk — arithmetic beyond the
791
865
  // measure is refused (the ledger).
792
- const marginal = query(Tax).rule((r) =>
793
- r
794
- .match(Regime, { id: r.var("reg"), year: r.param("y"), status: r.param("s") })
795
- .match(Bracket, { regime: r.var("reg"), income: r.var("b"), rate_bps: r.var("rate_bps") })
796
- .where(r.pointIn(r.param("taxable"), r.var("b")))
866
+ const marginal = query(Tax).rule((r) => {
867
+ const { reg, b, rate_bps } = r.vars("reg", "b", "rate_bps")
868
+ return r
869
+ .match(Regime, { id: reg, year: r.param("y"), status: r.param("s") })
870
+ .match(Bracket, { regime: reg, income: b, rate_bps })
871
+ .where(pointIn(r.param("taxable"), b))
797
872
  .select("rate_bps")
798
- )
873
+ })
799
874
  ```
800
875
 
801
876
  ## 18. Free time and coalescing
@@ -810,10 +885,8 @@ group, one answer per (group, segment). Coalescing is never a write rule: the
810
885
  engine stores the claims it was given.
811
886
 
812
887
  ```ts
813
- const PersonId = u64.as("PersonId")
814
-
815
- const Person = relation("Person", { id: PersonId.fresh, name: str })
816
- const Claim = relation("Claim", { person: PersonId, span: interval(i64) })
888
+ const Person = relation("Person", { id: u64.fresh, name: str })
889
+ const Claim = relation("Claim", { person: u64, span: interval(i64) })
817
890
 
818
891
  const FreeTime = schema("FreeTime", { Person, Claim }, [
819
892
  contained(on(Claim, "person"), on(Person, "id"))
@@ -822,13 +895,15 @@ const FreeTime = schema("FreeTime", { Person, Claim }, [
822
895
  ])
823
896
 
824
897
  // busy time, coalesced (adjacent segments merge — the half-open law):
825
- const busy = query(FreeTime).rule((r) =>
826
- r.match(Claim, { person: r.var("person"), span: r.var("span") }).select("person", r.pack("span"))
827
- )
898
+ const busy = query(FreeTime).rule((r) => {
899
+ const { person, span } = r.vars("person", "span")
900
+ return r.match(Claim, { person, span }).select("person", r.pack("span"))
901
+ })
828
902
  // raw claimed time (overlaps double-count — often the wrong question):
829
- const claimed = query(FreeTime).rule((r) =>
830
- r.match(Claim, { person: r.var("person"), span: r.var("span") }).select("person", r.sum(r.duration("span")))
831
- )
903
+ const claimed = query(FreeTime).rule((r) => {
904
+ const { person, span } = r.vars("person", "span")
905
+ return r.match(Claim, { person, span }).select("person", r.sum(r.duration("span")))
906
+ })
832
907
  // Coalesced totals = the two-query composition (pack, then a host fold) —
833
908
  // aggregates never nest; free time (gaps) is the two-line host walk over
834
909
  // sorted packed answers — both refusals recorded in the ledger.
@@ -845,16 +920,12 @@ for double entry — statements resolve posting references, not arithmetic agree
845
920
  The census workload. Balance is a query, never a column.
846
921
 
847
922
  ```ts
848
- const AccountId = u64.as("AccountId")
849
- const JournalEntryId = u64.as("JournalEntryId")
850
- const PostingId = u64.as("PostingId")
851
-
852
- const Account = relation("Account", { id: AccountId.fresh, name: str })
853
- const JournalEntry = relation("JournalEntry", { id: JournalEntryId.fresh, at: i64, memo: str })
923
+ const Account = relation("Account", { id: u64.fresh, name: str })
924
+ const JournalEntry = relation("JournalEntry", { id: u64.fresh, at: i64, memo: str })
854
925
  const Posting = relation("Posting", {
855
- id: PostingId.fresh,
856
- entry: JournalEntryId,
857
- account: AccountId,
926
+ id: u64.fresh,
927
+ entry: u64,
928
+ account: u64,
858
929
  minor: i64
859
930
  })
860
931
 
@@ -868,17 +939,15 @@ const Ledger = schema("Ledger", { Account, JournalEntry, Posting }, [
868
939
  ])
869
940
 
870
941
  // balances (bind the fresh id — set semantics collapses duplicates):
871
- const balances = query(Ledger).rule((r) =>
872
- r
873
- .match(Posting, { id: r.var("id"), account: r.var("account"), minor: r.var("minor") })
874
- .select("account", r.sum("minor"))
875
- )
942
+ const balances = query(Ledger).rule((r) => {
943
+ const { id, account, minor } = r.vars("id", "account", "minor")
944
+ return r.match(Posting, { id, account, minor }).select("account", r.sum("minor"))
945
+ })
876
946
  // double-entry audit (host asserts every total is 0 — discipline, not schema):
877
- const doubleEntry = query(Ledger).rule((r) =>
878
- r
879
- .match(Posting, { id: r.var("id"), entry: r.var("entry"), minor: r.var("minor") })
880
- .select("entry", r.sum("minor"))
881
- )
947
+ const doubleEntry = query(Ledger).rule((r) => {
948
+ const { id, entry, minor } = r.vars("id", "entry", "minor")
949
+ return r.match(Posting, { id, entry, minor }).select("entry", r.sum("minor"))
950
+ })
882
951
  ```
883
952
 
884
953
  ## 20. Conditional writes
@@ -895,24 +964,26 @@ the host's own interleaved writes), and `abandon(payload)` declines to commit
895
964
  without issuing anything.
896
965
 
897
966
  ```ts
898
- const JobId = u64.as("JobId")
899
-
900
967
  const State = closed("State", ["Queued", "Running", "Done"])
901
- const Job = relation("Job", { id: JobId.fresh, state: State.id, payload: str })
902
- const Lease = relation("Lease", { job: JobId, worker: u64, until: i64 })
968
+ const Job = relation("Job", { id: u64.fresh, state: State.id, payload: str })
969
+ const Lease = relation("Lease", { job: u64, worker: u64, until: i64 })
903
970
 
904
971
  const Jobs = schema("Jobs", { State, Job, Lease }, [
905
972
  contained(on(Job, "state"), on(State, "id")),
906
973
  key(Lease, ["job"]),
907
974
  // A lease exists iff its job is Running (recipe 13's conditional target):
908
975
  // claiming a job and leasing it commit together or not at all.
909
- mirrors(on(Lease, "job"), on(Job.where({ state: State.Running }), "id"))
976
+ mirrors(on(Lease, "job"), on(Job.where({ state: "Running" }), "id"))
910
977
  ])
911
978
 
912
979
  // update-where's premise — "still Queued" is the witness:
913
- const stillQueued = query(Jobs).rule((r) =>
914
- r.match(Job, { id: r.var("id"), state: State.Queued, payload: r.var("payload") }).select("id", "payload")
915
- )
980
+ const stillQueued = query(Jobs).rule((r) => {
981
+ const { id, payload } = r.vars("id", "payload")
982
+ return r.match(Job, { id, state: "Queued", payload }).select("id", "payload")
983
+ })
984
+
985
+ const db = await Db.create("./jobs.db", Jobs)
986
+ const prepared = db.prepare(stillQueued)
916
987
 
917
988
  // The witnessed loop: premise reads via `snap`, the delta via `tx`; on a
918
989
  // moved generation the WHOLE callback reruns on a fresh snapshot. The other
@@ -925,8 +996,8 @@ const outcome = db.writeWitnessed(function updateWhere(snap, tx) {
925
996
  return abandon("nothing queued")
926
997
  }
927
998
  for (const row of queued) {
928
- tx.delete(Job, { id: row.id, state: State.Queued, payload: row.payload })
929
- tx.insert(Job, { id: row.id, state: State.Running, payload: row.payload })
999
+ tx.delete(Job, { id: row.id, state: "Queued", payload: row.payload })
1000
+ tx.insert(Job, { id: row.id, state: "Running", payload: row.payload })
930
1001
  tx.insert(Lease, { job: row.id, worker: 7n, until: 60n })
931
1002
  }
932
1003
  return undefined
@@ -956,16 +1027,17 @@ const Rollup = schema("Rollup", { Arm, Claim, BusySpan }, [
956
1027
  // Soundness, pointwise: every stored rollup point is covered by busy
957
1028
  // claims — an UNSOUND rollup (claiming busy time that isn't, or surviving
958
1029
  // its sources' deletion) cannot commit, judged on every touching commit.
959
- contained(on(BusySpan, ["person", "span"]), on(Claim.where({ arm: Arm.Busy }), ["person", "span"]))
1030
+ contained(on(BusySpan, ["person", "span"]), on(Claim.where({ arm: "Busy" }), ["person", "span"]))
960
1031
  ])
961
1032
 
962
1033
  // Maintenance is the third witness idiom (recipe 20): re-run the deriving
963
1034
  // query on a snapshot, diff, commit witnessed — the rollup cannot commit
964
1035
  // against sources it didn't actually read. The deriving query (pack IS the
965
1036
  // coalesce):
966
- const deriving = query(Rollup).rule((r) =>
967
- r.match(Claim, { person: r.var("person"), span: r.var("span"), arm: Arm.Busy }).select("person", r.pack("span"))
968
- )
1037
+ const deriving = query(Rollup).rule((r) => {
1038
+ const { person, span } = r.vars("person", "span")
1039
+ return r.match(Claim, { person, span, arm: "Busy" }).select("person", r.pack("span"))
1040
+ })
969
1041
  ```
970
1042
 
971
1043
  ## 22. Union reads
@@ -979,37 +1051,37 @@ The whole-DU read is a set of rules: one head, one rule per arm — disjunction
979
1051
  is data at the top, never an execution node.
980
1052
 
981
1053
  ```ts
982
- const PaymentId = u64.as("PaymentId")
983
-
984
1054
  const Kind = closed("Kind", ["Card", "Ach"])
985
- const Payment = relation("Payment", { id: PaymentId.fresh, kind: Kind.id })
986
- const Card = relation("Card", { payment: PaymentId, last4: u64 })
987
- const Ach = relation("Ach", { payment: PaymentId, routing: u64 })
1055
+ const Payment = relation("Payment", { id: u64.fresh, kind: Kind.id })
1056
+ const Card = relation("Card", { payment: u64, last4: u64 })
1057
+ const Ach = relation("Ach", { payment: u64, routing: u64 })
988
1058
 
989
1059
  const Payments = schema("Payments", { Kind, Payment, Card, Ach }, [
990
1060
  contained(on(Payment, "kind"), on(Kind, "id")),
991
1061
  key(Card, ["payment"]),
992
1062
  key(Ach, ["payment"]),
993
- mirrors(on(Payment.where({ kind: Kind.Card }), "id"), on(Card, "payment")),
994
- mirrors(on(Payment.where({ kind: Kind.Ach }), "id"), on(Ach, "payment"))
1063
+ mirrors(on(Payment.where({ kind: "Card" }), "id"), on(Card, "payment")),
1064
+ mirrors(on(Payment.where({ kind: "Ach" }), "id"), on(Ach, "payment"))
995
1065
  ])
996
1066
 
997
1067
  // One query, two rules (set union). The exclusivity theorem (recipe 2) is
998
1068
  // spent a third time here: rules selecting different `kind` handles are
999
1069
  // provably disjoint, so the executor elides cross-rule dedup — the free lunch.
1000
1070
  const wholeDu = query(Payments)
1001
- .rule((r) =>
1002
- r
1003
- .match(Payment, { id: r.var("id"), kind: Kind.Card })
1004
- .match(Card, { payment: r.var("id"), last4: r.var("n") })
1071
+ .rule((r) => {
1072
+ const { id, n } = r.vars("id", "n")
1073
+ return r
1074
+ .match(Payment, { id, kind: "Card" })
1075
+ .match(Card, { payment: id, last4: n })
1005
1076
  .select("id", "n")
1006
- )
1007
- .rule((r) =>
1008
- r
1009
- .match(Payment, { id: r.var("id"), kind: Kind.Ach })
1010
- .match(Ach, { payment: r.var("id"), routing: r.var("n") })
1077
+ })
1078
+ .rule((r) => {
1079
+ const { id, n } = r.vars("id", "n")
1080
+ return r
1081
+ .match(Payment, { id, kind: "Ach" })
1082
+ .match(Ach, { payment: id, routing: n })
1011
1083
  .select("id", "n")
1012
- )
1084
+ })
1013
1085
  ```
1014
1086
 
1015
1087
  ## 23. The anti-recipes: five gravestones
@@ -1021,8 +1093,6 @@ What not to model. Each gravestone cites its replacement; the block's
1021
1093
  relations are the replacements, compiled.
1022
1094
 
1023
1095
  ```ts
1024
- const GravestoneEventId = u64.as("GravestoneEventId")
1025
-
1026
1096
  // GRAVESTONE: successor pointers (a `next` column). A linked list inside a
1027
1097
  // relation is control flow smuggled into data. REPLACEMENT: the ordering
1028
1098
  // triple (recipe 9).
@@ -1039,7 +1109,7 @@ const ActiveRun = relation("ActiveRun", { student: u64, run: u64 })
1039
1109
  const Usage = relation("Usage", { meter: u64, period: u64, used: interval(i64) })
1040
1110
  // GRAVESTONE: uuid keys. uuidv7 is identity + clash-avoidance + clock in
1041
1111
  // one lie. REPLACEMENT: fresh (minted identity) + an explicit i64 time column.
1042
- const Event = relation("Event", { id: GravestoneEventId.fresh, at: i64 })
1112
+ const Event = relation("Event", { id: u64.fresh, at: i64 })
1043
1113
 
1044
1114
  const Gravestones = schema("Gravestones", { Step, Score, ActiveRun, Usage, Event }, [
1045
1115
  key(Step, ["flow", "pos"]),
@@ -1065,12 +1135,10 @@ semi-naive evaluation's Δ, spent where a loop is a loop: the host. The
1065
1135
  engine-native form is the same closure as one stratified `program()`.
1066
1136
 
1067
1137
  ```ts
1068
- const NodeId = u64.as("NodeId")
1069
-
1070
- const Node = relation("Node", { id: NodeId.fresh, name: str })
1138
+ const Node = relation("Node", { id: u64.fresh, name: str })
1071
1139
  // One parent per child — a forest (recipe 10's edge shape); a root is a
1072
1140
  // node whose Parent fact is absent (recipe 3's honest 0..1).
1073
- const Parent = relation("Parent", { child: NodeId, parent: NodeId })
1141
+ const Parent = relation("Parent", { child: u64, parent: u64 })
1074
1142
 
1075
1143
  const Closure = schema("Closure", { Node, Parent }, [
1076
1144
  key(Parent, ["child"]),
@@ -1079,13 +1147,20 @@ const Closure = schema("Closure", { Node, Parent }, [
1079
1147
  ])
1080
1148
 
1081
1149
  // The loop's one query — the frontier's children, one ∈-set probe:
1082
- const step = query(Closure).rule((r) => r.match(Parent, { child: r.var("c"), parent: r.inSet("frontier") }).select("c"))
1150
+ const step = query(Closure).rule((r) => {
1151
+ const { c } = r.vars("c")
1152
+ return r.match(Parent, { child: c, parent: r.inSet("frontier") }).select("c")
1153
+ })
1083
1154
  ```
1084
1155
 
1085
1156
  The loop (the compiled, driven copy is in `test/cookbook.test.ts`, over a
1086
1157
  three-level forest with the exact reachable set asserted):
1087
1158
 
1088
1159
  ```ts
1160
+ const db = await Db.create("./closure.db", Closure)
1161
+ const stepPrepared = db.prepare(step)
1162
+ const root = 1n // the host's chosen root node id
1163
+
1089
1164
  const seen = new Set<bigint>([root])
1090
1165
  let frontier: readonly bigint[] = [root]
1091
1166
  for (;;) {
@@ -1116,19 +1191,24 @@ position, so the head rides the `Node` atom):
1116
1191
  const reach = program(Closure, (p) => {
1117
1192
  const rec = p.rec("reach")
1118
1193
  const seeded = rec
1119
- .rule((r) =>
1120
- r
1121
- .match(Node, { id: r.var("c") })
1122
- .where(r.eq(r.var("c"), r.param("root")))
1194
+ .rule((r) => {
1195
+ const { c } = r.vars("c")
1196
+ return r
1197
+ .match(Node, { id: c })
1198
+ .where(eq(c, r.param("root")))
1123
1199
  .select("c")
1124
- )
1125
- .rule((r) =>
1126
- r
1127
- .match(Parent, { child: r.var("c"), parent: r.var("m") })
1128
- .idb(rec, r.var("m"))
1200
+ })
1201
+ .rule((r) => {
1202
+ const { c, parent } = r.vars("c", "parent")
1203
+ return r
1204
+ .match(Parent, { child: c, parent })
1205
+ .idb(rec, parent)
1129
1206
  .select("c")
1130
- )
1131
- return p.output((r) => r.match(Node, { id: r.var("c") }).idb(seeded, r.var("c")).select("c"))
1207
+ })
1208
+ return p.output((r) => {
1209
+ const { c } = r.vars("c")
1210
+ return r.match(Node, { id: c }).idb(seeded, c).select("c")
1211
+ })
1132
1212
  })
1133
1213
  const reachPrepared = db.prepare(reach)
1134
1214
  ```
@@ -1155,12 +1235,9 @@ aggregation *through* a cycle is refused, but a fold over a recursive
1155
1235
  predicate from a **higher stratum** reads a finished set and is ordinary.
1156
1236
 
1157
1237
  ```ts
1158
- const AccountId = u64.as("AccountId")
1159
- const PostingId = u64.as("PostingId")
1160
-
1161
- const Account = relation("Account", { id: AccountId.fresh, name: str })
1162
- const AccountParent = relation("AccountParent", { child: AccountId, parent: AccountId })
1163
- const Posting = relation("Posting", { id: PostingId.fresh, account: AccountId, minor: i64 })
1238
+ const Account = relation("Account", { id: u64.fresh, name: str })
1239
+ const AccountParent = relation("AccountParent", { child: u64, parent: u64 })
1240
+ const Posting = relation("Posting", { id: u64.fresh, account: u64, minor: i64 })
1164
1241
 
1165
1242
  const Accounts = schema("Accounts", { Account, AccountParent, Posting }, [
1166
1243
  key(AccountParent, ["child"]), // one parent per account
@@ -1171,37 +1248,42 @@ const Accounts = schema("Accounts", { Account, AccountParent, Posting }, [
1171
1248
 
1172
1249
  // The two queries the host rollup composes:
1173
1250
  // the frontier step (recipe 24's loop, verbatim):
1174
- const frontierStep = query(Accounts).rule((r) =>
1175
- r.match(AccountParent, { child: r.var("c"), parent: r.inSet("frontier") }).select("c")
1176
- )
1251
+ const frontierStep = query(Accounts).rule((r) => {
1252
+ const { c } = r.vars("c")
1253
+ return r.match(AccountParent, { child: c, parent: r.inSet("frontier") }).select("c")
1254
+ })
1177
1255
  // the rollup over the accumulated subtree (bind the fresh id — recipe
1178
1256
  // 19's discipline, spent again; equal postings to one account both count):
1179
- const subtreeRollup = query(Accounts).rule((r) =>
1180
- r.match(Posting, { id: r.var("id"), account: r.inSet("subtree"), minor: r.var("minor") }).select(r.sum("minor"))
1181
- )
1257
+ const subtreeRollup = query(Accounts).rule((r) => {
1258
+ const { id, minor } = r.vars("id", "minor")
1259
+ return r.match(Posting, { id, account: r.inSet("subtree"), minor }).select(r.sum("minor"))
1260
+ })
1182
1261
  // The engine-native form: the closure stratum converges first, then the
1183
1262
  // output's fold runs once over the finished subtree.
1184
1263
  const nativeRollup = program(Accounts, (p) => {
1185
1264
  const sub = p.rec("sub")
1186
1265
  const seeded = sub
1187
- .rule((r) =>
1188
- r
1189
- .match(Account, { id: r.var("a") })
1190
- .where(r.eq(r.var("a"), r.param("root")))
1266
+ .rule((r) => {
1267
+ const { a } = r.vars("a")
1268
+ return r
1269
+ .match(Account, { id: a })
1270
+ .where(eq(a, r.param("root")))
1191
1271
  .select("a")
1192
- )
1193
- .rule((r) =>
1194
- r
1195
- .match(AccountParent, { child: r.var("a"), parent: r.var("p") })
1196
- .idb(sub, r.var("p"))
1272
+ })
1273
+ .rule((r) => {
1274
+ const { a, parent } = r.vars("a", "parent")
1275
+ return r
1276
+ .match(AccountParent, { child: a, parent })
1277
+ .idb(sub, parent)
1197
1278
  .select("a")
1198
- )
1199
- return p.output((r) =>
1200
- r
1201
- .match(Posting, { id: r.var("id"), account: r.var("a"), minor: r.var("minor") })
1202
- .idb(seeded, r.var("a"))
1279
+ })
1280
+ return p.output((r) => {
1281
+ const { id, a, minor } = r.vars("id", "a", "minor")
1282
+ return r
1283
+ .match(Posting, { id, account: a, minor })
1284
+ .idb(seeded, a)
1203
1285
  .select(r.sum("minor"))
1204
- )
1286
+ })
1205
1287
  })
1206
1288
  ```
1207
1289
 
@@ -1221,10 +1303,8 @@ targets resolve by their exact projected field set, so the fresh `{id}` key
1221
1303
  cannot serve the `{id, live}` target and the engine infers no key closure.
1222
1304
 
1223
1305
  ```ts
1224
- const PolicyId = u64.as("PolicyId")
1225
-
1226
- const Policy = relation("Policy", { id: PolicyId.fresh, live: interval(i64) })
1227
- const Version = relation("Version", { policy: PolicyId, valid: interval(i64) })
1306
+ const Policy = relation("Policy", { id: u64.fresh, live: interval(i64) })
1307
+ const Version = relation("Version", { policy: u64, valid: interval(i64) })
1228
1308
 
1229
1309
  const ExactPartition = schema("ExactPartition", { Policy, Version }, [
1230
1310
  contained(on(Version, "policy"), on(Policy, "id")), // reference intent
@@ -1263,15 +1343,14 @@ const MaintainedRollup = schema("MaintainedRollup", { Arm, Claim, BusySpan }, [
1263
1343
  key(Claim, ["source"]),
1264
1344
  key(Claim, ["person", "span"]),
1265
1345
  key(BusySpan, ["person", "span"]),
1266
- contained(on(BusySpan, ["person", "span"]), on(Claim.where({ arm: Arm.Busy }), ["person", "span"]))
1346
+ contained(on(BusySpan, ["person", "span"]), on(Claim.where({ arm: "Busy" }), ["person", "span"]))
1267
1347
  ])
1268
1348
 
1269
1349
  // Derive the desired rollup on the maintenance snapshot:
1270
- const deriving = query(MaintainedRollup).rule((r) =>
1271
- r
1272
- .match(Claim, { source: r.var("source"), person: r.var("person"), arm: Arm.Busy, span: r.var("span") })
1273
- .select("person", r.pack("span"))
1274
- )
1350
+ const deriving = query(MaintainedRollup).rule((r) => {
1351
+ const { source, person, span } = r.vars("source", "person", "span")
1352
+ return r.match(Claim, { source, person, arm: "Busy", span }).select("person", r.pack("span"))
1353
+ })
1275
1354
  ```
1276
1355
 
1277
1356
  The host loop is `db.writeWitnessed`: derive on the attempt's snapshot, diff,
@@ -1306,31 +1385,30 @@ as an interval with a pointwise key: one salary per employee per instant. The
1306
1385
  transform supplies the missing dimension (a ray from the migration epoch).
1307
1386
 
1308
1387
  ```ts
1309
- const EmployeeId = u64.as("EmployeeId")
1310
-
1311
1388
  // The old theory, judged and fingerprinted:
1312
- const EmployeeV1 = relation("Employee", { id: EmployeeId.fresh, name: str })
1313
- const SalaryV1 = relation("Salary", { employee: EmployeeId, amount: i64 })
1389
+ const EmployeeV1 = relation("Employee", { id: u64.fresh, name: str })
1390
+ const SalaryV1 = relation("Salary", { employee: u64, amount: i64 })
1314
1391
  const PayrollV1 = schema("PayrollV1", { Employee: EmployeeV1, Salary: SalaryV1 }, [
1315
1392
  contained(on(SalaryV1, "employee"), on(EmployeeV1, "id"))
1316
1393
  ])
1317
1394
 
1318
1395
  // The new theory adds what v1 never recorded:
1319
- const Employee = relation("Employee", { id: EmployeeId.fresh, name: str })
1320
- const Salary = relation("Salary", { employee: EmployeeId, amount: i64, applies: interval(i64) })
1396
+ const Employee = relation("Employee", { id: u64.fresh, name: str })
1397
+ const Salary = relation("Salary", { employee: u64, amount: i64, applies: interval(i64) })
1321
1398
  const Payroll = schema("Payroll", { Employee, Salary }, [
1322
1399
  contained(on(Salary, "employee"), on(Employee, "id")),
1323
1400
  key(Salary, ["employee", "applies"]) // one salary per instant
1324
1401
  ])
1325
1402
 
1326
1403
  // The post-migration read — salaries in force at an instant:
1327
- const inForceAt = query(Payroll).rule((r) =>
1328
- r
1329
- .match(Employee, { id: r.var("e"), name: r.var("name") })
1330
- .match(Salary, { employee: r.var("e"), amount: r.var("amount"), applies: r.var("w") })
1331
- .where(r.pointIn(r.param("at"), r.var("w")))
1404
+ const inForceAt = query(Payroll).rule((r) => {
1405
+ const { e, name, amount, w } = r.vars("e", "name", "amount", "w")
1406
+ return r
1407
+ .match(Employee, { id: e, name })
1408
+ .match(Salary, { employee: e, amount, applies: w })
1409
+ .where(pointIn(r.param("at"), w))
1332
1410
  .select("name", "amount")
1333
- )
1411
+ })
1334
1412
  ```
1335
1413
 
1336
1414
  The engine's compiled test drives the whole loop (export under one snapshot,
@@ -1363,15 +1441,13 @@ widths are enforced **by type**: a `UnitSlot` value is width 1 or does not
1363
1441
  exist — no runtime width check, nothing to enforce at commit.
1364
1442
 
1365
1443
  ```ts
1366
- const LedgerId = u64.as("LedgerId")
1367
-
1368
1444
  const Kind = closed("Kind", ["Unit", "Pair"])
1369
- const Ledger = relation("Ledger", { id: LedgerId.fresh, name: str })
1445
+ const Ledger = relation("Ledger", { id: u64.fresh, name: str })
1370
1446
  // The witness: every zone of the ledger, kind-discriminated; its one
1371
1447
  // pointwise key is the cross-sidecar disjointness proof.
1372
- const Zone = relation("Zone", { ledger: LedgerId, kind: Kind.id, at: interval(u64) })
1373
- const UnitSlot = relation("UnitSlot", { ledger: LedgerId, at: interval(u64, 1n), entry: u64 })
1374
- const PairSlot = relation("PairSlot", { ledger: LedgerId, at: interval(u64, 2n), entry: u64 })
1448
+ const Zone = relation("Zone", { ledger: u64, kind: Kind.id, at: interval(u64) })
1449
+ const UnitSlot = relation("UnitSlot", { ledger: u64, at: interval(u64, 1n), entry: u64 })
1450
+ const PairSlot = relation("PairSlot", { ledger: u64, at: interval(u64, 2n), entry: u64 })
1375
1451
 
1376
1452
  const ZoneLedger = schema("ZoneLedger", { Kind, Ledger, Zone, UnitSlot, PairSlot }, [
1377
1453
  contained(on(Zone, "ledger"), on(Ledger, "id")),
@@ -1381,8 +1457,8 @@ const ZoneLedger = schema("ZoneLedger", { Kind, Ledger, Zone, UnitSlot, PairSlot
1381
1457
  key(PairSlot, ["ledger", "at"]),
1382
1458
  // Each kind's zones carry exactly its sidecar's points — mixed widths,
1383
1459
  // one element domain:
1384
- mirrors(on(Zone.where({ kind: Kind.Unit }), ["ledger", "at"]), on(UnitSlot, ["ledger", "at"])),
1385
- mirrors(on(Zone.where({ kind: Kind.Pair }), ["ledger", "at"]), on(PairSlot, ["ledger", "at"]))
1460
+ mirrors(on(Zone.where({ kind: "Unit" }), ["ledger", "at"]), on(UnitSlot, ["ledger", "at"])),
1461
+ mirrors(on(Zone.where({ kind: "Pair" }), ["ledger", "at"]), on(PairSlot, ["ledger", "at"]))
1386
1462
  ])
1387
1463
  ```
1388
1464