@rhizomes/rhizomatic 0.1.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 (119) hide show
  1. package/LICENSE-APACHE +201 -0
  2. package/LICENSE-MIT +21 -0
  3. package/README.md +54 -0
  4. package/dist/alias.d.ts +4 -0
  5. package/dist/alias.d.ts.map +1 -0
  6. package/dist/alias.js +34 -0
  7. package/dist/alias.js.map +1 -0
  8. package/dist/cbor.d.ts +24 -0
  9. package/dist/cbor.d.ts.map +1 -0
  10. package/dist/cbor.js +267 -0
  11. package/dist/cbor.js.map +1 -0
  12. package/dist/delta.d.ts +8 -0
  13. package/dist/delta.d.ts.map +1 -0
  14. package/dist/delta.js +92 -0
  15. package/dist/delta.js.map +1 -0
  16. package/dist/derivation.d.ts +29 -0
  17. package/dist/derivation.d.ts.map +1 -0
  18. package/dist/derivation.js +183 -0
  19. package/dist/derivation.js.map +1 -0
  20. package/dist/eval.d.ts +91 -0
  21. package/dist/eval.d.ts.map +1 -0
  22. package/dist/eval.js +318 -0
  23. package/dist/eval.js.map +1 -0
  24. package/dist/hash.d.ts +4 -0
  25. package/dist/hash.d.ts.map +1 -0
  26. package/dist/hash.js +17 -0
  27. package/dist/hash.js.map +1 -0
  28. package/dist/http.d.ts +21 -0
  29. package/dist/http.d.ts.map +1 -0
  30. package/dist/http.js +110 -0
  31. package/dist/http.js.map +1 -0
  32. package/dist/hview.d.ts +15 -0
  33. package/dist/hview.d.ts.map +1 -0
  34. package/dist/hview.js +72 -0
  35. package/dist/hview.js.map +1 -0
  36. package/dist/index.d.ts +23 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +22 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/json-profile.d.ts +4 -0
  41. package/dist/json-profile.d.ts.map +1 -0
  42. package/dist/json-profile.js +97 -0
  43. package/dist/json-profile.js.map +1 -0
  44. package/dist/pack.d.ts +5 -0
  45. package/dist/pack.d.ts.map +1 -0
  46. package/dist/pack.js +227 -0
  47. package/dist/pack.js.map +1 -0
  48. package/dist/peer.d.ts +26 -0
  49. package/dist/peer.d.ts.map +1 -0
  50. package/dist/peer.js +111 -0
  51. package/dist/peer.js.map +1 -0
  52. package/dist/policy.d.ts +46 -0
  53. package/dist/policy.d.ts.map +1 -0
  54. package/dist/policy.js +186 -0
  55. package/dist/policy.js.map +1 -0
  56. package/dist/pred.d.ts +78 -0
  57. package/dist/pred.d.ts.map +1 -0
  58. package/dist/pred.js +228 -0
  59. package/dist/pred.js.map +1 -0
  60. package/dist/reactor.d.ts +67 -0
  61. package/dist/reactor.d.ts.map +1 -0
  62. package/dist/reactor.js +433 -0
  63. package/dist/reactor.js.map +1 -0
  64. package/dist/schema-deltas.d.ts +14 -0
  65. package/dist/schema-deltas.d.ts.map +1 -0
  66. package/dist/schema-deltas.js +87 -0
  67. package/dist/schema-deltas.js.map +1 -0
  68. package/dist/schema.d.ts +17 -0
  69. package/dist/schema.d.ts.map +1 -0
  70. package/dist/schema.js +102 -0
  71. package/dist/schema.js.map +1 -0
  72. package/dist/set.d.ts +18 -0
  73. package/dist/set.d.ts.map +1 -0
  74. package/dist/set.js +83 -0
  75. package/dist/set.js.map +1 -0
  76. package/dist/sign.d.ts +8 -0
  77. package/dist/sign.d.ts.map +1 -0
  78. package/dist/sign.js +44 -0
  79. package/dist/sign.js.map +1 -0
  80. package/dist/term-io.d.ts +13 -0
  81. package/dist/term-io.d.ts.map +1 -0
  82. package/dist/term-io.js +216 -0
  83. package/dist/term-io.js.map +1 -0
  84. package/dist/term-json.d.ts +7 -0
  85. package/dist/term-json.d.ts.map +1 -0
  86. package/dist/term-json.js +362 -0
  87. package/dist/term-json.js.map +1 -0
  88. package/dist/types.d.ts +34 -0
  89. package/dist/types.d.ts.map +1 -0
  90. package/dist/types.js +4 -0
  91. package/dist/types.js.map +1 -0
  92. package/dist/vocab.d.ts +2 -0
  93. package/dist/vocab.d.ts.map +1 -0
  94. package/dist/vocab.js +4 -0
  95. package/dist/vocab.js.map +1 -0
  96. package/package.json +83 -0
  97. package/src/alias.ts +36 -0
  98. package/src/cbor.ts +280 -0
  99. package/src/delta.ts +89 -0
  100. package/src/derivation.ts +229 -0
  101. package/src/eval.ts +401 -0
  102. package/src/hash.ts +19 -0
  103. package/src/http.ts +124 -0
  104. package/src/hview.ts +91 -0
  105. package/src/index.ts +83 -0
  106. package/src/json-profile.ts +96 -0
  107. package/src/pack.ts +239 -0
  108. package/src/peer.ts +126 -0
  109. package/src/policy.ts +216 -0
  110. package/src/pred.ts +307 -0
  111. package/src/reactor.ts +490 -0
  112. package/src/schema-deltas.ts +100 -0
  113. package/src/schema.ts +111 -0
  114. package/src/set.ts +98 -0
  115. package/src/sign.ts +48 -0
  116. package/src/term-io.ts +228 -0
  117. package/src/term-json.ts +364 -0
  118. package/src/types.ts +38 -0
  119. package/src/vocab.ts +3 -0
package/src/cbor.ts ADDED
@@ -0,0 +1,280 @@
1
+ // Deterministic CBOR encoder — the Rhizomatic v0 canonicalization profile (RFC 8949 §4.2.1,
2
+ // as refined in spec/01-delta.ERRATA.md). Hand-rolled on purpose: this must reproduce the Rust
3
+ // encoder byte-for-byte, and the only way to guarantee that is to own every byte.
4
+ //
5
+ // Supported data items (exactly what the delta model needs): text string, float (numbers),
6
+ // bool, definite-length array, definite-length map (text-string keys, sorted).
7
+
8
+ export type CborValue =
9
+ | { readonly t: "tstr"; readonly v: string }
10
+ | { readonly t: "float"; readonly v: number }
11
+ | { readonly t: "bool"; readonly v: boolean }
12
+ | { readonly t: "array"; readonly v: readonly CborValue[] }
13
+ | { readonly t: "map"; readonly v: ReadonlyArray<readonly [string, CborValue]> };
14
+
15
+ export const tstr = (v: string): CborValue => ({ t: "tstr", v });
16
+ export const float = (v: number): CborValue => ({ t: "float", v });
17
+ export const bool = (v: boolean): CborValue => ({ t: "bool", v });
18
+ export const array = (v: readonly CborValue[]): CborValue => ({ t: "array", v });
19
+ export const map = (v: ReadonlyArray<readonly [string, CborValue]>): CborValue => ({ t: "map", v });
20
+
21
+ class ByteSink {
22
+ private readonly bytes: number[] = [];
23
+ push(...b: number[]): void {
24
+ for (const x of b) this.bytes.push(x & 0xff);
25
+ }
26
+ pushBytes(arr: Uint8Array): void {
27
+ for (const x of arr) this.bytes.push(x);
28
+ }
29
+ toUint8Array(): Uint8Array {
30
+ return Uint8Array.from(this.bytes);
31
+ }
32
+ }
33
+
34
+ // Write a CBOR head: major type (high 3 bits) plus an unsigned argument, shortest form.
35
+ function writeHead(sink: ByteSink, major: number, arg: number): void {
36
+ const mt = major << 5;
37
+ if (arg < 24) {
38
+ sink.push(mt | arg);
39
+ } else if (arg < 0x100) {
40
+ sink.push(mt | 24, arg);
41
+ } else if (arg < 0x10000) {
42
+ sink.push(mt | 25, (arg >> 8) & 0xff, arg & 0xff);
43
+ } else if (arg <= 0xffffffff) {
44
+ sink.push(mt | 26, (arg >>> 24) & 0xff, (arg >>> 16) & 0xff, (arg >>> 8) & 0xff, arg & 0xff);
45
+ } else {
46
+ // Only reachable for absurd lengths; included for totality.
47
+ const hi = Math.floor(arg / 0x100000000);
48
+ const lo = arg >>> 0;
49
+ sink.push(
50
+ mt | 27,
51
+ (hi >>> 24) & 0xff,
52
+ (hi >>> 16) & 0xff,
53
+ (hi >>> 8) & 0xff,
54
+ hi & 0xff,
55
+ (lo >>> 24) & 0xff,
56
+ (lo >>> 16) & 0xff,
57
+ (lo >>> 8) & 0xff,
58
+ lo & 0xff,
59
+ );
60
+ }
61
+ }
62
+
63
+ const fbuf = new DataView(new ArrayBuffer(8));
64
+
65
+ // Returns the IEEE-754 binary16 bit pattern for n if (and only if) f16 represents n exactly,
66
+ // else null. n must be finite and exactly representable as f32 (caller guarantees both).
67
+ function tryF16Bits(n: number): number | null {
68
+ fbuf.setFloat32(0, n);
69
+ const bits = fbuf.getUint32(0);
70
+ const sign = ((bits >>> 31) & 1) << 15;
71
+ const exp = (bits >>> 23) & 0xff;
72
+ const mant = bits & 0x7fffff;
73
+ if (exp === 0 && mant === 0) return sign; // zero (-0 already normalized away by caller)
74
+ const e = exp - 127; // unbiased exponent (f32 subnormals land at -127 and fall through)
75
+ if (e >= -14 && e <= 15) {
76
+ // f16 normal range: the 23-bit mantissa must fit in 10 bits.
77
+ if ((mant & 0x1fff) !== 0) return null;
78
+ return sign | ((e + 15) << 10) | (mant >>> 13);
79
+ }
80
+ if (e >= -24 && e <= -15) {
81
+ // f16 subnormal range: value must be an exact multiple of 2^-24.
82
+ const shift = -(e + 1); // 14..23
83
+ const sig = 0x800000 | mant; // full 24-bit significand
84
+ if ((sig & ((1 << shift) - 1)) !== 0) return null;
85
+ return sign | (sig >>> shift);
86
+ }
87
+ return null;
88
+ }
89
+
90
+ // ERRATA D1: numbers encode as float only, in the shortest of f16/f32/f64 that represents the
91
+ // value exactly (RFC 8949 §4.2.1). -0.0 is normalized to +0.0.
92
+ function writeFloat(sink: ByteSink, value: number): void {
93
+ if (!Number.isFinite(value)) {
94
+ throw new Error(`non-finite number is not representable: ${value}`);
95
+ }
96
+ const n = value + 0; // normalize -0 to +0
97
+ if (Math.fround(n) === n) {
98
+ const h = tryF16Bits(n);
99
+ if (h !== null) {
100
+ sink.push(0xf9, (h >>> 8) & 0xff, h & 0xff);
101
+ return;
102
+ }
103
+ fbuf.setFloat32(0, n);
104
+ sink.push(0xfa, fbuf.getUint8(0), fbuf.getUint8(1), fbuf.getUint8(2), fbuf.getUint8(3));
105
+ } else {
106
+ fbuf.setFloat64(0, n);
107
+ sink.push(
108
+ 0xfb,
109
+ fbuf.getUint8(0),
110
+ fbuf.getUint8(1),
111
+ fbuf.getUint8(2),
112
+ fbuf.getUint8(3),
113
+ fbuf.getUint8(4),
114
+ fbuf.getUint8(5),
115
+ fbuf.getUint8(6),
116
+ fbuf.getUint8(7),
117
+ );
118
+ }
119
+ }
120
+
121
+ function cmpBytes(a: Uint8Array, b: Uint8Array): number {
122
+ const n = Math.min(a.length, b.length);
123
+ for (let i = 0; i < n; i++) {
124
+ const d = a[i]! - b[i]!;
125
+ if (d !== 0) return d;
126
+ }
127
+ return a.length - b.length;
128
+ }
129
+
130
+ const utf8 = new TextEncoder();
131
+
132
+ function encodeInto(sink: ByteSink, val: CborValue): void {
133
+ switch (val.t) {
134
+ case "tstr": {
135
+ const bytes = utf8.encode(val.v.normalize("NFC"));
136
+ writeHead(sink, 3, bytes.length);
137
+ sink.pushBytes(bytes);
138
+ return;
139
+ }
140
+ case "bool":
141
+ sink.push(val.v ? 0xf5 : 0xf4);
142
+ return;
143
+ case "float":
144
+ writeFloat(sink, val.v);
145
+ return;
146
+ case "array":
147
+ writeHead(sink, 4, val.v.length);
148
+ for (const item of val.v) encodeInto(sink, item);
149
+ return;
150
+ case "map": {
151
+ // ERRATA D4: sort entries by bytewise lex order of the encoded key.
152
+ const entries = val.v.map(([k, v]) => {
153
+ const ks = new ByteSink();
154
+ encodeInto(ks, tstr(k));
155
+ return { key: ks.toUint8Array(), value: v };
156
+ });
157
+ entries.sort((a, b) => cmpBytes(a.key, b.key));
158
+ for (let i = 1; i < entries.length; i++) {
159
+ if (cmpBytes(entries[i - 1]!.key, entries[i]!.key) === 0) {
160
+ throw new Error("duplicate map key in canonical CBOR");
161
+ }
162
+ }
163
+ writeHead(sink, 5, entries.length);
164
+ for (const e of entries) {
165
+ sink.pushBytes(e.key);
166
+ encodeInto(sink, e.value);
167
+ }
168
+ return;
169
+ }
170
+ }
171
+ }
172
+
173
+ export function encode(val: CborValue): Uint8Array {
174
+ const sink = new ByteSink();
175
+ encodeInto(sink, val);
176
+ return sink.toUint8Array();
177
+ }
178
+
179
+ // --- strict decoder for the Rhizomatic profile ----------------------------------------------------
180
+ // Accepts exactly the items the profile emits: definite text strings, f16/f32/f64 floats, bools,
181
+ // definite arrays, definite maps with text keys. Everything else (ints, byte strings, tags,
182
+ // indefinite lengths, null/undefined) is rejected. Canonicality is checked by re-encoding where a
183
+ // caller needs it; this decoder checks structure only.
184
+
185
+ const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
186
+
187
+ class ByteReader {
188
+ private pos = 0;
189
+ constructor(private readonly bytes: Uint8Array) {}
190
+ u8(): number {
191
+ if (this.pos >= this.bytes.length) throw new Error("cbor: unexpected end of input");
192
+ return this.bytes[this.pos++]!;
193
+ }
194
+ take(n: number): Uint8Array {
195
+ if (this.pos + n > this.bytes.length) throw new Error("cbor: unexpected end of input");
196
+ const out = this.bytes.subarray(this.pos, this.pos + n);
197
+ this.pos += n;
198
+ return out;
199
+ }
200
+ done(): boolean {
201
+ return this.pos === this.bytes.length;
202
+ }
203
+ }
204
+
205
+ function readLength(r: ByteReader, info: number): number {
206
+ if (info < 24) return info;
207
+ if (info === 24) return r.u8();
208
+ if (info === 25) return (r.u8() << 8) | r.u8();
209
+ if (info === 26) return ((r.u8() << 24) | (r.u8() << 16) | (r.u8() << 8) | r.u8()) >>> 0;
210
+ throw new Error(`cbor: unsupported length encoding (info ${info})`);
211
+ }
212
+
213
+ function f16BitsToNumber(bits: number): number {
214
+ const sign = bits & 0x8000 ? -1 : 1;
215
+ const exp = (bits >> 10) & 0x1f;
216
+ const mant = bits & 0x3ff;
217
+ if (exp === 0) return sign * mant * 2 ** -24;
218
+ if (exp === 31) throw new Error("cbor: non-finite f16 is not representable");
219
+ return sign * (1 + mant / 1024) * 2 ** (exp - 15);
220
+ }
221
+
222
+ function decodeItem(r: ByteReader): CborValue {
223
+ const head = r.u8();
224
+ const major = head >> 5;
225
+ const info = head & 0x1f;
226
+ switch (major) {
227
+ case 3: {
228
+ const len = readLength(r, info);
229
+ return tstr(utf8Decoder.decode(r.take(len)));
230
+ }
231
+ case 4: {
232
+ const len = readLength(r, info);
233
+ const items: CborValue[] = [];
234
+ for (let i = 0; i < len; i++) items.push(decodeItem(r));
235
+ return array(items);
236
+ }
237
+ case 5: {
238
+ const len = readLength(r, info);
239
+ const entries: Array<[string, CborValue]> = [];
240
+ for (let i = 0; i < len; i++) {
241
+ const key = decodeItem(r);
242
+ if (key.t !== "tstr") throw new Error("cbor: map keys must be text strings");
243
+ entries.push([key.v, decodeItem(r)]);
244
+ }
245
+ return map(entries);
246
+ }
247
+ case 7: {
248
+ if (info === 20) return bool(false);
249
+ if (info === 21) return bool(true);
250
+ if (info === 25) {
251
+ const b = r.take(2);
252
+ return float(f16BitsToNumber((b[0]! << 8) | b[1]!));
253
+ }
254
+ if (info === 26) {
255
+ const b = r.take(4);
256
+ const dv = new DataView(b.buffer, b.byteOffset, 4);
257
+ const n = dv.getFloat32(0);
258
+ if (!Number.isFinite(n)) throw new Error("cbor: non-finite float is not representable");
259
+ return float(n);
260
+ }
261
+ if (info === 27) {
262
+ const b = r.take(8);
263
+ const dv = new DataView(b.buffer, b.byteOffset, 8);
264
+ const n = dv.getFloat64(0);
265
+ if (!Number.isFinite(n)) throw new Error("cbor: non-finite float is not representable");
266
+ return float(n);
267
+ }
268
+ throw new Error(`cbor: unsupported simple/float (info ${info})`);
269
+ }
270
+ default:
271
+ throw new Error(`cbor: major type ${major} is outside the Rhizomatic profile`);
272
+ }
273
+ }
274
+
275
+ export function decode(bytes: Uint8Array): CborValue {
276
+ const r = new ByteReader(bytes);
277
+ const v = decodeItem(r);
278
+ if (!r.done()) throw new Error("cbor: trailing bytes after item");
279
+ return v;
280
+ }
package/src/delta.ts ADDED
@@ -0,0 +1,89 @@
1
+ import { type CborValue, array, bool, encode, float, map, tstr } from "./cbor.js";
2
+ import { bytesToHex, contentAddress } from "./hash.js";
3
+ import type { Claims, Pointer, Target } from "./types.js";
4
+
5
+ function targetToCbor(t: Target): CborValue {
6
+ switch (t.kind) {
7
+ case "primitive": {
8
+ const v = t.value;
9
+ if (typeof v === "string") return tstr(v);
10
+ if (typeof v === "boolean") return bool(v);
11
+ return float(v);
12
+ }
13
+ case "entity": {
14
+ const entries: Array<[string, CborValue]> = [["id", tstr(t.entity.id)]];
15
+ if (t.entity.context !== undefined) entries.push(["context", tstr(t.entity.context)]);
16
+ return map(entries);
17
+ }
18
+ case "delta": {
19
+ const entries: Array<[string, CborValue]> = [["delta", tstr(t.deltaRef.delta)]];
20
+ if (t.deltaRef.context !== undefined) entries.push(["context", tstr(t.deltaRef.context)]);
21
+ return map(entries);
22
+ }
23
+ }
24
+ }
25
+
26
+ function pointerToCbor(p: Pointer): CborValue {
27
+ return map([
28
+ ["role", tstr(p.role)],
29
+ ["target", targetToCbor(p.target)],
30
+ ]);
31
+ }
32
+
33
+ export function claimsToCbor(claims: Claims): CborValue {
34
+ return map([
35
+ ["author", tstr(claims.author)],
36
+ ["pointers", array(claims.pointers.map(pointerToCbor))],
37
+ ["timestamp", float(claims.timestamp)],
38
+ ]);
39
+ }
40
+
41
+ function assertNfc(s: string, what: string): void {
42
+ if (s.normalize("NFC") !== s) {
43
+ throw new Error(`${what} must be NFC-normalized (ERRATA D11): ${JSON.stringify(s)}`);
44
+ }
45
+ }
46
+
47
+ // Reject malformed claims at the boundary; never repair (SPEC-4 §2).
48
+ export function assertValidClaims(claims: Claims): void {
49
+ if (claims.author.length === 0) throw new Error("author must be non-empty");
50
+ assertNfc(claims.author, "author");
51
+ if (!Number.isFinite(claims.timestamp)) throw new Error("timestamp must be finite");
52
+ if (claims.pointers.length < 1) throw new Error("a delta MUST contain at least one pointer");
53
+ for (const p of claims.pointers) {
54
+ if (p.role.length === 0) throw new Error("role must be non-empty");
55
+ assertNfc(p.role, "role");
56
+ if (p.target.kind === "primitive") {
57
+ const v = p.target.value;
58
+ if (typeof v === "number" && !Number.isFinite(v)) {
59
+ throw new Error("numeric primitive must be finite");
60
+ }
61
+ if (typeof v === "string") assertNfc(v, "string primitive");
62
+ }
63
+ if (p.target.kind === "entity") assertNfc(p.target.entity.id, "entity id");
64
+ if (p.target.kind === "delta") assertNfc(p.target.deltaRef.delta, "delta ref");
65
+ const ctx =
66
+ p.target.kind === "entity"
67
+ ? p.target.entity.context
68
+ : p.target.kind === "delta"
69
+ ? p.target.deltaRef.context
70
+ : undefined;
71
+ if (ctx !== undefined) {
72
+ if (ctx.length === 0) throw new Error("context, when present, must be non-empty");
73
+ assertNfc(ctx, "context");
74
+ }
75
+ }
76
+ }
77
+
78
+ export function canonicalBytes(claims: Claims): Uint8Array {
79
+ assertValidClaims(claims);
80
+ return encode(claimsToCbor(claims));
81
+ }
82
+
83
+ export function canonicalHex(claims: Claims): string {
84
+ return bytesToHex(canonicalBytes(claims));
85
+ }
86
+
87
+ export function computeId(claims: Claims): string {
88
+ return contentAddress(canonicalBytes(claims));
89
+ }
@@ -0,0 +1,229 @@
1
+ // The derivation layer (SPEC-7, ERRATA-7): everything that computes is an author. Derived
2
+ // authors read materializations and write signed claims back through the ordinary ingest path.
3
+
4
+ import { computeId } from "./delta.js";
5
+ import type { HView } from "./hview.js";
6
+ import { Reactor, type IngestResult, type MaterializationChange } from "./reactor.js";
7
+ import { VOCAB_PREFIX } from "./schema-deltas.js";
8
+ import { makeNegationClaims } from "./set.js";
9
+ import { authorForSeed, signClaims, verifyDelta } from "./sign.js";
10
+ import type { Claims, Delta, Pointer } from "./types.js";
11
+
12
+ // A v0 derived function: substantive pointer lists, one per claim to emit (G1).
13
+ export type DerivedFn = (view: HView, root: string) => Pointer[][];
14
+
15
+ export interface BindingSpec {
16
+ readonly name: string; // binding entity id
17
+ readonly fnId: string; // fn entity id (declared identity; WASM hash later, G1)
18
+ readonly materialization: string;
19
+ readonly pure: boolean;
20
+ readonly budget: number; // lifetime emission-trigger cap (G2)
21
+ readonly emit: "append" | "supersede" | { readonly keyed: readonly string[] };
22
+ }
23
+
24
+ interface Installed {
25
+ readonly spec: BindingSpec;
26
+ readonly fn: DerivedFn;
27
+ readonly seedHex: string;
28
+ readonly author: string;
29
+ // Live (un-superseded) emission ids, bucketed by subject key (G4). append/supersede use the
30
+ // single bucket ""; keyed buckets by emissionKey.
31
+ liveEmissions: Map<string, string[]>;
32
+ triggerCount: number;
33
+ suspended: boolean;
34
+ }
35
+
36
+ // The subject key of an emission under keyed(contextSet): sorted (entity id, context) pairs of
37
+ // the substantive entity pointers whose context is in the set; "" when none match (G4).
38
+ function emissionKey(substantive: readonly Pointer[], contexts: readonly string[]): string {
39
+ const pairs: string[] = [];
40
+ for (const p of substantive) {
41
+ if (p.target.kind !== "entity") continue;
42
+ const ctx = p.target.entity.context;
43
+ if (ctx !== undefined && contexts.includes(ctx)) {
44
+ pairs.push(`${p.target.entity.id}${ctx}`);
45
+ }
46
+ }
47
+ return pairs.sort().join("");
48
+ }
49
+
50
+ function provenancePointers(spec: BindingSpec, inputHex: string): Pointer[] {
51
+ return [
52
+ {
53
+ role: `${VOCAB_PREFIX}.derived.by`,
54
+ target: { kind: "entity", entity: { id: spec.fnId } },
55
+ },
56
+ { role: `${VOCAB_PREFIX}.derived.from`, target: { kind: "primitive", value: inputHex } },
57
+ {
58
+ role: `${VOCAB_PREFIX}.derived.under`,
59
+ target: { kind: "entity", entity: { id: spec.name } },
60
+ },
61
+ ];
62
+ }
63
+
64
+ // Build the full claims for one emission — the exact recipe replay verification re-runs (G5).
65
+ export function derivedClaims(
66
+ spec: BindingSpec,
67
+ author: string,
68
+ substantive: readonly Pointer[],
69
+ inputHex: string,
70
+ ): Claims {
71
+ // timestamp 0: pure output must be a function of (fn, input hash) only (G3).
72
+ return {
73
+ timestamp: 0,
74
+ author,
75
+ pointers: [...substantive, ...provenancePointers(spec, inputHex)],
76
+ };
77
+ }
78
+
79
+ export class DerivationHost {
80
+ private readonly bindings = new Map<string, Installed>();
81
+
82
+ constructor(readonly reactor: Reactor) {}
83
+
84
+ // Installation is an assertion: a signed rdb.derived.binds delta (SPEC-7 §3).
85
+ install(spec: BindingSpec, fn: DerivedFn, seedHex: string): string {
86
+ if (this.bindings.has(spec.name)) throw new Error(`duplicate binding: ${spec.name}`);
87
+ const author = authorForSeed(seedHex);
88
+ const binds = signClaims(
89
+ {
90
+ timestamp: 0,
91
+ author,
92
+ pointers: [
93
+ {
94
+ role: `${VOCAB_PREFIX}.derived.binds`,
95
+ target: { kind: "entity", entity: { id: spec.fnId, context: "bindings" } },
96
+ },
97
+ { role: `${VOCAB_PREFIX}.derived.author`, target: { kind: "primitive", value: author } },
98
+ ],
99
+ },
100
+ seedHex,
101
+ );
102
+ this.reactor.ingest(binds);
103
+ this.bindings.set(spec.name, {
104
+ spec,
105
+ fn,
106
+ seedHex,
107
+ author,
108
+ liveEmissions: new Map(),
109
+ triggerCount: 0,
110
+ suspended: false,
111
+ });
112
+ return author;
113
+ }
114
+
115
+ isSuspended(name: string): boolean {
116
+ return this.bindings.get(name)?.suspended ?? false;
117
+ }
118
+
119
+ authorOf(name: string): string | undefined {
120
+ return this.bindings.get(name)?.author;
121
+ }
122
+
123
+ // The write-back loop (G2): ingest, then drain triggers until quiescent.
124
+ ingest(delta: Delta): IngestResult {
125
+ const result = this.reactor.ingest(delta);
126
+ if (result.status !== "accepted") return result;
127
+ this.drain([...this.reactor.changesFromLastIngest()]);
128
+ return result;
129
+ }
130
+
131
+ private drain(pending: MaterializationChange[]): void {
132
+ let depth = 0;
133
+ while (pending.length > 0 && depth < 32) {
134
+ depth += 1;
135
+ const next: MaterializationChange[] = [];
136
+ for (const change of pending) {
137
+ for (const b of this.bindings.values()) {
138
+ if (b.spec.materialization !== change.materialization) continue;
139
+ next.push(...this.trigger(b, change));
140
+ }
141
+ }
142
+ pending = next;
143
+ }
144
+ }
145
+
146
+ private emitSigned(b: Installed, claims: Claims): MaterializationChange[] {
147
+ const signed = signClaims(claims, b.seedHex);
148
+ const result = this.reactor.ingest(signed);
149
+ if (result.status === "rejected") throw new Error("derived emission rejected");
150
+ return result.status === "accepted" ? [...this.reactor.changesFromLastIngest()] : [];
151
+ }
152
+
153
+ private trigger(b: Installed, change: MaterializationChange): MaterializationChange[] {
154
+ if (b.suspended) return [];
155
+ // The default non-reentrancy guard (SPEC-7 §6): skip when the trigger is entirely our own.
156
+ const own = change.responsibleDeltaIds.every(
157
+ (id) => this.reactor.get(id)?.claims.author === b.author,
158
+ );
159
+ if (own) return [];
160
+ if (b.triggerCount >= b.spec.budget) {
161
+ b.suspended = true;
162
+ // Divergence becomes an observable event, not a melted reactor (G2).
163
+ return this.emitSigned(b, {
164
+ timestamp: 0,
165
+ author: b.author,
166
+ pointers: [
167
+ {
168
+ role: `${VOCAB_PREFIX}.derived.suspended`,
169
+ target: { kind: "entity", entity: { id: b.spec.name, context: "suspensions" } },
170
+ },
171
+ ],
172
+ });
173
+ }
174
+ b.triggerCount += 1;
175
+ const view = this.reactor.materializedView(change.materialization, change.root);
176
+ if (view === undefined) return [];
177
+ const out: MaterializationChange[] = [];
178
+ if (b.spec.emit === "supersede") {
179
+ // Wholesale supersession: negate every live emission before emitting anew (G4).
180
+ for (const prior of [...b.liveEmissions.values()].flat()) {
181
+ out.push(...this.emitSigned(b, makeNegationClaims(b.author, 0, prior)));
182
+ }
183
+ b.liveEmissions.clear();
184
+ }
185
+ const keyed = typeof b.spec.emit === "object" ? b.spec.emit.keyed : undefined;
186
+ for (const substantive of b.fn(view, change.root)) {
187
+ const claims = derivedClaims(b.spec, b.author, substantive, change.newHex);
188
+ const signed = signClaims(claims, b.seedHex);
189
+ const key = keyed === undefined ? "" : emissionKey(substantive, keyed);
190
+ // Per-subject supersession: negate only the same-key priors; an empty key appends (G4).
191
+ if (keyed !== undefined && key !== "") {
192
+ for (const prior of b.liveEmissions.get(key) ?? []) {
193
+ out.push(...this.emitSigned(b, makeNegationClaims(b.author, 0, prior)));
194
+ }
195
+ b.liveEmissions.set(key, []);
196
+ }
197
+ const result = this.reactor.ingest(signed);
198
+ if (result.status === "accepted") {
199
+ const bucket = b.liveEmissions.get(key);
200
+ if (bucket === undefined) b.liveEmissions.set(key, [signed.id]);
201
+ else bucket.push(signed.id);
202
+ out.push(...this.reactor.changesFromLastIngest());
203
+ }
204
+ }
205
+ return out;
206
+ }
207
+ }
208
+
209
+ // Pure-replay verification (SPEC-7 §4, G5): recompute the emission from (fn, input view) and
210
+ // compare content addresses; the signature must also verify.
211
+ export function verifyPureDerivation(
212
+ emitted: Delta,
213
+ spec: BindingSpec,
214
+ fn: DerivedFn,
215
+ view: HView,
216
+ root: string,
217
+ viewHex: string,
218
+ ): boolean {
219
+ if (verifyDelta(emitted) !== "verified") return false;
220
+ const fromPtr = emitted.claims.pointers.find(
221
+ (p) => p.role === `${VOCAB_PREFIX}.derived.from` && p.target.kind === "primitive",
222
+ );
223
+ if (fromPtr?.target.kind !== "primitive" || fromPtr.target.value !== viewHex) return false;
224
+ // Re-derive ids: the replayed claims must content-address to the emitted delta's id.
225
+ const ids = fn(view, root).map((substantive) =>
226
+ computeId(derivedClaims(spec, emitted.claims.author, substantive, viewHex)),
227
+ );
228
+ return ids.includes(emitted.id);
229
+ }