@bitspark/horos 0.0.0-stage → 0.7.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.
@@ -0,0 +1,936 @@
1
+ /*
2
+ * horos — the form contract (TypeScript core).
3
+ *
4
+ * A byte-for-byte port of the Rust reference core (core/rs/src/lib.rs) and the Go
5
+ * core (core/go/horos.go). It implements the v1 judgment surface — wf(T) and v : T
6
+ * (`conforms`) — over the horos/* descriptor DSL, as TOTAL, SEARCH-FREE structural
7
+ * recursion over the finite trees of the value v and the descriptor T (both ordinary
8
+ * ontos-core Values). Scalar and collection recognition delegate to the frozen
9
+ * ontos/data recognizers; horos re-mints nothing.
10
+ *
11
+ * See docs/spec/horos.md (the frozen v1 surface) and
12
+ * docs/architecture/decisions/0002-form-reasoning-cut.md.
13
+ *
14
+ * Both `wf` and `conforms` are TOTAL (they never throw), SEARCH-FREE, and
15
+ * byte-for-byte equivalent to the Rust reference; the shared
16
+ * vectors/conformance.json oracle pins the three cores (go/rs/ts) together.
17
+ *
18
+ * rec/ref guarded recursion
19
+ * -------------------------
20
+ * `rec` binds a recursion name to its body descriptor; `ref` unfolds it, but only
21
+ * when guarded — every unfolding must have descended into a strict substructure of v
22
+ * since the binding. `env` binds each in-scope rec name to its body; `pending` holds
23
+ * the names bound or unfolded at the current v-node WITHOUT a descent since, so a
24
+ * `ref` to a pending name is unguarded (it would loop) and yields false. Descending
25
+ * into a child value clears `pending` (a constructor of v was consumed); same-v
26
+ * combinators (union/and/not) thread it unchanged. Termination: between two
27
+ * unfoldings of the same `ref` there must be a descent, and each descent strictly
28
+ * shrinks the finite v — so unfoldings are bounded by |v|.
29
+ */
30
+ import { Atom, Tuple, bytesEqual } from "@bitspark/ontos-core";
31
+ import { readBool, readInt, readList, readMap, readSet, readText, recognizeInt, } from "@bitspark/ontos-data";
32
+ /**
33
+ * The reserved descriptor-label namespace. Every descriptor combinator is a
34
+ * `Tuple(Atom("horos/<ctor>"), args...)`; a tuple whose first child is not an atom
35
+ * under this prefix is not a horos descriptor (per ontos-data §1.1 reservation).
36
+ */
37
+ export const NAMESPACE = "horos/";
38
+ /**
39
+ * The inclusive i128 range an `atom-len` bound must fit in to be well-formed (horos-internal#18):
40
+ * `[-2^127, 2^127 - 1]`. The Rust reference reads bounds into an `i128` and errors
41
+ * (→ non-wf / false) past this range; our `readInt` returns an unbounded `bigint`, so we
42
+ * apply the SAME cap explicitly via {@link atomLenBoundInRange}. The three cores then
43
+ * agree byte-for-byte at the cap — a bound = 2^127 is non-wf in all of them — and below
44
+ * it for every real byte-length bound. This makes atom-len byte-identity a theorem rather
45
+ * than a "never happens" argument; byte-length bounds are tiny, so it rejects nothing
46
+ * meaningful. (The Go core mirrors this with `atomLenBound*`, the Rust core implicitly via
47
+ * its `read_int` → i128 materialization.)
48
+ */
49
+ export const ATOM_LEN_BOUND_MAX = (1n << 127n) - 1n; // 2^127 - 1 = i128::MAX
50
+ export const ATOM_LEN_BOUND_MIN = -(1n << 127n); // -2^127 = i128::MIN
51
+ /**
52
+ * Whether a bound fits in the signed 128-bit range — the explicit analogue of the Rust
53
+ * core's `read_int` succeeding (vs. returning `IntOutOfRange`). A bound outside the range
54
+ * makes the atom-len descriptor non-wf / non-conforming, matching Rust and Go.
55
+ */
56
+ function atomLenBoundInRange(n) {
57
+ return n >= ATOM_LEN_BOUND_MIN && n <= ATOM_LEN_BOUND_MAX;
58
+ }
59
+ /**
60
+ * The shared recursion-depth guard (horos-internal#37) — the SAME constant in all three cores (Rust
61
+ * `MAX_RECURSION_DEPTH`, Go `MaxRecursionDepth`). `conformsIn`/`wfIn` recurse on the
62
+ * native stack; termination is bounded by `|v|` (the `pending` guard) but there is no
63
+ * stack bound, so an adversarial-but-finite value (e.g. a tuple nested hundreds of
64
+ * thousands deep) could overflow the stack — a `RangeError: Maximum call stack size
65
+ * exceeded` — at a core-dependent depth, breaking the total/never-throws contract.
66
+ *
67
+ * Past `MAX_RECURSION_DEPTH` frames, `conformsIn`/`wfIn` return a DETERMINISTIC result
68
+ * (`false` / non-wf), never a throw, at the same logical depth as Rust and Go (each
69
+ * increments a depth counter at identical recursion points). Termination is thus bounded
70
+ * by `min(|v|, MAX_RECURSION_DEPTH)`; the cap is a robustness guardrail, not a change to
71
+ * the recognized language for in-bound inputs. `512` is far above any descriptor/value
72
+ * the spec, tests, or vectors exercise (< ~30, so no judgment changes) and comfortably
73
+ * below the smallest native stack budget of the three cores (validated empirically).
74
+ */
75
+ export const MAX_RECURSION_DEPTH = 512;
76
+ const utf8Decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
77
+ /** Push `head` onto `tail`, returning a new node that shares `tail` (no copy). */
78
+ function cons(head, tail) {
79
+ return { head, tail };
80
+ }
81
+ /** Whether the name chain holds `name` (by content) — the analogue of `pending.contains`. */
82
+ function consContainsName(s, name) {
83
+ for (let n = s; n !== undefined; n = n.tail) {
84
+ if (bytesEqual(n.head, name))
85
+ return true;
86
+ }
87
+ return false;
88
+ }
89
+ /**
90
+ * If `t` is `Tuple(Atom("horos/<ctor>"), args...)` with a valid-UTF-8 label under the
91
+ * reserved namespace, return `{ ctor, args }`. Otherwise `undefined` (not a horos
92
+ * descriptor). Total — never throws.
93
+ *
94
+ * The Rust core requires the label to be valid UTF-8 (`str::from_utf8`); we mirror
95
+ * that with a fatal UTF-8 decode. Every ctor we match against is ASCII, so a non-UTF-8
96
+ * label never matches a known ctor and falls through to a miss either way; the explicit
97
+ * validity gate mirrors Rust/Go without changing any decision.
98
+ */
99
+ function head(t) {
100
+ if (!(t instanceof Tuple))
101
+ return undefined;
102
+ const items = t.items();
103
+ if (items.length === 0)
104
+ return undefined;
105
+ const labelChild = items[0];
106
+ if (!(labelChild instanceof Atom))
107
+ return undefined;
108
+ let label;
109
+ try {
110
+ label = utf8Decoder.decode(labelChild.bytes());
111
+ }
112
+ catch {
113
+ return undefined;
114
+ }
115
+ if (!label.startsWith(NAMESPACE))
116
+ return undefined;
117
+ return { ctor: label.slice(NAMESPACE.length), args: items.slice(1) };
118
+ }
119
+ /** Read the bytes of an atom value, or `undefined` if it is a tuple. */
120
+ function atomBytes(v) {
121
+ return v instanceof Atom ? v.bytes() : undefined;
122
+ }
123
+ // ---------------------------------------------------------------------------
124
+ // data-encoded refinements (horos-internal#68) — the four named grammar-class predicates the
125
+ // visual-atlas consumer needs (atlas#533 Q4 / visual-atlas#534), each a closed,
126
+ // total, deterministic, single-value predicate (spec §5 admissibility). They
127
+ // measure Unicode CHARACTERS (code points), not bytes, so the multi-byte cases
128
+ // where char-count ≠ byte-count agree with the Rust and Go cores. Implemented as
129
+ // explicit, dependency-free code-point scans (no RegExp) so the three cores decide
130
+ // byte-for-byte. CHARACTER count means CODE POINTS, not UTF-16 units: `[...s]` /
131
+ // `Array.from(s)` iterate the string by code point (a single astral character is
132
+ // one unit), matching Rust's `chars()` and Go's runes exactly. The §5 grammar
133
+ // literals are ASCII, so a non-ASCII code point never satisfies a grammar class.
134
+ /** Whether a code point is an ASCII lowercase letter a–z. */
135
+ function isAsciiLower(cp) {
136
+ return cp >= 0x61 && cp <= 0x7a;
137
+ }
138
+ /** Whether a code point is an ASCII digit 0–9. */
139
+ function isAsciiDigit(cp) {
140
+ return cp >= 0x30 && cp <= 0x39;
141
+ }
142
+ /** Whether a code point is an ASCII hex digit 0–9 / a–f (lowercase only). */
143
+ function isLowerHex(cp) {
144
+ return isAsciiDigit(cp) || (cp >= 0x61 && cp <= 0x66);
145
+ }
146
+ /**
147
+ * The fixed ASCII whitespace set used by {@link isNonBlank} — mirrored byte-for-byte
148
+ * in the Rust (`is_ascii_ws`) and Go (`isASCIIWS`) cores: space, tab, LF, CR, VT, FF.
149
+ * Deliberately NOT Unicode `White_Space` (which varies by runtime and would break
150
+ * tri-core parity).
151
+ */
152
+ function isAsciiWs(cp) {
153
+ return (cp === 0x20 || cp === 0x09 || cp === 0x0a || cp === 0x0d || cp === 0x0b || cp === 0x0c);
154
+ }
155
+ /**
156
+ * `horos/key-grammar`: the decoded text matches `^[a-z0-9][a-z0-9._-]{0,63}$` — a
157
+ * per-code-point class test over the decoded string (1–64 code points total).
158
+ */
159
+ function matchesKeyGrammar(s) {
160
+ const cps = [...s];
161
+ if (cps.length === 0 || cps.length > 64)
162
+ return false;
163
+ const first = cps[0].codePointAt(0);
164
+ if (!(isAsciiLower(first) || isAsciiDigit(first)))
165
+ return false;
166
+ for (let i = 1; i < cps.length; i += 1) {
167
+ const cp = cps[i].codePointAt(0);
168
+ if (!(isAsciiLower(cp) || isAsciiDigit(cp) || cp === 0x2e || cp === 0x5f || cp === 0x2d)) {
169
+ return false; // . _ -
170
+ }
171
+ }
172
+ return true;
173
+ }
174
+ /**
175
+ * `horos/text-non-blank`: the decoded text has at least one non-whitespace code
176
+ * point. A non-ASCII code point is never whitespace (so it counts as non-blank),
177
+ * mirroring the Rust/Go cores exactly.
178
+ */
179
+ function isNonBlank(s) {
180
+ for (const ch of s) {
181
+ if (!isAsciiWs(ch.codePointAt(0)))
182
+ return true;
183
+ }
184
+ return false;
185
+ }
186
+ /**
187
+ * `horos/lowercase-uuid`: the decoded text matches the lowercase canonical UUID
188
+ * grammar `^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$` — scanned
189
+ * by code point so a multi-byte char in a malformed input is rejected cleanly.
190
+ */
191
+ function matchesLowercaseUuid(s) {
192
+ const groups = [8, 4, 4, 4, 12];
193
+ const cps = [...s];
194
+ let pos = 0;
195
+ for (let gi = 0; gi < groups.length; gi += 1) {
196
+ for (let i = 0; i < groups[gi]; i += 1) {
197
+ if (pos >= cps.length || !isLowerHex(cps[pos].codePointAt(0)))
198
+ return false;
199
+ pos += 1;
200
+ }
201
+ if (gi < groups.length - 1) {
202
+ if (pos >= cps.length || cps[pos].codePointAt(0) !== 0x2d)
203
+ return false; // hyphen
204
+ pos += 1;
205
+ }
206
+ }
207
+ return pos === cps.length; // no trailing code points
208
+ }
209
+ // ===========================================================================
210
+ // conformance — v : T
211
+ // ===========================================================================
212
+ /**
213
+ * Decide `v : T` — "does the value `v` exhibit the form described by `T`?"
214
+ *
215
+ * TOTAL and search-free. A value that does not conform — and a `T` that is not a
216
+ * well-formed descriptor — both yield `false`; this never errors and never throws. A
217
+ * value may conform to many descriptors at once (overlapping classification, not a
218
+ * new identity).
219
+ */
220
+ export function conforms(v, t) {
221
+ // Soundness gate (spec §3 MUST): `v : T` presupposes `wf(T)`. A descriptor that is
222
+ // not well-formed admits no values, even where an individual arm would otherwise
223
+ // match — so check `wf` once at the entry and never arm-by-arm below.
224
+ return wf(t) && conformsIn(v, t, undefined, undefined, 0);
225
+ }
226
+ /**
227
+ * `conforms` with the recursion environment threaded through:
228
+ *
229
+ * - `env` binds each in-scope `rec` name to its body descriptor.
230
+ * - `pending` holds the names bound or unfolded at the current v-node without a
231
+ * descent since — a `ref` to a pending name is unguarded (it would loop) and
232
+ * yields `false`. Descending into a child value clears `pending`; same-v
233
+ * combinators (union/and/not) thread it unchanged.
234
+ */
235
+ function conformsIn(v, t, env, pending, depth) {
236
+ // Recursion-depth guard (horos-internal#37): past MAX_RECURSION_DEPTH frames, return a deterministic
237
+ // `false` rather than risk a native-stack overflow (RangeError). Counted at the same
238
+ // point in all three cores so the trip depth is byte-identical.
239
+ if (depth > MAX_RECURSION_DEPTH)
240
+ return false;
241
+ const h = head(t);
242
+ if (h === undefined)
243
+ return false; // not a horos descriptor
244
+ const { ctor, args } = h;
245
+ switch (ctor) {
246
+ // Top.
247
+ case "any":
248
+ return args.length === 0;
249
+ // Atoms.
250
+ case "atom":
251
+ return args.length === 0 && v instanceof Atom;
252
+ case "atom-exact": {
253
+ if (args.length !== 1)
254
+ return false;
255
+ const vb = atomBytes(v);
256
+ const bb = atomBytes(args[0]);
257
+ if (vb === undefined || bb === undefined)
258
+ return false;
259
+ return bytesEqual(vb, bb);
260
+ }
261
+ case "atom-len": {
262
+ if (args.length !== 2)
263
+ return false;
264
+ const vb = atomBytes(v);
265
+ if (vb === undefined)
266
+ return false;
267
+ // Bounds use readInt (materialize). Our readInt returns an unbounded `bigint`,
268
+ // whereas the Rust reference's read_int materializes into an `i128` and errors
269
+ // (→ false) for any bound past that width. We close that divergence (horos-internal#18) by
270
+ // applying the SAME i128 cap explicitly via atomLenBoundInRange: a bound outside
271
+ // [ATOM_LEN_BOUND_MIN, ATOM_LEN_BOUND_MAX] is rejected here, exactly as Rust's
272
+ // read_int returns IntOutOfRange. So the cores agree at the cap (a bound = 2^127 is
273
+ // non-conforming everywhere) and below it for every real byte-length bound.
274
+ let lo;
275
+ let hi;
276
+ try {
277
+ lo = readInt(args[0]);
278
+ hi = readInt(args[1]);
279
+ }
280
+ catch {
281
+ return false;
282
+ }
283
+ if (!atomLenBoundInRange(lo) || !atomLenBoundInRange(hi))
284
+ return false;
285
+ const len = BigInt(vb.length);
286
+ return lo <= len && len <= hi;
287
+ }
288
+ case "atom-in": {
289
+ if (args.length !== 1)
290
+ return false;
291
+ const vb = atomBytes(v);
292
+ if (vb === undefined)
293
+ return false;
294
+ let elems;
295
+ try {
296
+ elems = readSet(args[0]);
297
+ }
298
+ catch {
299
+ return false;
300
+ }
301
+ for (const e of elems) {
302
+ const eb = atomBytes(e);
303
+ if (eb !== undefined && bytesEqual(eb, vb))
304
+ return true;
305
+ }
306
+ return false;
307
+ }
308
+ // Text refinements (horos-internal#68) — each first recognizes v as a canonical utf8-text
309
+ // (delegating to the frozen ontos/data recognizer, re-minting nothing) and then
310
+ // applies a closed, total, single-value character predicate. Char-count (code
311
+ // points, not bytes) is measured, so multi-byte text is bounded/matched
312
+ // identically across the three cores.
313
+ case "text-len": {
314
+ if (args.length !== 2)
315
+ return false;
316
+ let s;
317
+ let lo;
318
+ let hi;
319
+ try {
320
+ s = readText(v);
321
+ lo = readInt(args[0]);
322
+ hi = readInt(args[1]);
323
+ }
324
+ catch {
325
+ return false;
326
+ }
327
+ if (!atomLenBoundInRange(lo) || !atomLenBoundInRange(hi))
328
+ return false;
329
+ const n = BigInt([...s].length); // code points, not UTF-16 units
330
+ return lo <= n && n <= hi;
331
+ }
332
+ case "key-grammar": {
333
+ if (args.length !== 0)
334
+ return false;
335
+ let s;
336
+ try {
337
+ s = readText(v);
338
+ }
339
+ catch {
340
+ return false;
341
+ }
342
+ return matchesKeyGrammar(s);
343
+ }
344
+ case "text-non-blank": {
345
+ if (args.length !== 0)
346
+ return false;
347
+ let s;
348
+ try {
349
+ s = readText(v);
350
+ }
351
+ catch {
352
+ return false;
353
+ }
354
+ return isNonBlank(s);
355
+ }
356
+ case "lowercase-uuid": {
357
+ if (args.length !== 0)
358
+ return false;
359
+ let s;
360
+ try {
361
+ s = readText(v);
362
+ }
363
+ catch {
364
+ return false;
365
+ }
366
+ return matchesLowercaseUuid(s);
367
+ }
368
+ // Scalars — delegate to the frozen ontos/data recognizers.
369
+ case "scalar": {
370
+ if (args.length !== 1)
371
+ return false;
372
+ const k = atomBytes(args[0]);
373
+ if (k === undefined)
374
+ return false;
375
+ const kind = bytesToAscii(k);
376
+ switch (kind) {
377
+ // Structural recognition — a canonical value beyond a binding's host integer
378
+ // width is still recognized (the materialization limit is not a recognition
379
+ // miss, ontos-data §5.1), so all cores agree. horos re-mints nothing.
380
+ case "int":
381
+ return ok(() => recognizeInt(v));
382
+ case "utf8-text":
383
+ return ok(() => readText(v));
384
+ case "bool":
385
+ return ok(() => readBool(v));
386
+ default:
387
+ return false;
388
+ }
389
+ }
390
+ // Tuples & compounds. Each child descriptor types a child VALUE — a descent, so
391
+ // the guard (pending) is cleared.
392
+ case "tuple": {
393
+ if (!(v instanceof Tuple))
394
+ return false;
395
+ const items = v.items();
396
+ if (items.length !== args.length)
397
+ return false;
398
+ for (let i = 0; i < items.length; i += 1) {
399
+ if (!conformsIn(items[i], args[i], env, undefined, depth + 1))
400
+ return false;
401
+ }
402
+ return true;
403
+ }
404
+ case "seq": {
405
+ if (args.length !== 1)
406
+ return false;
407
+ if (!(v instanceof Tuple))
408
+ return false;
409
+ for (const c of v.items()) {
410
+ if (!conformsIn(c, args[0], env, undefined, depth + 1))
411
+ return false;
412
+ }
413
+ return true;
414
+ }
415
+ case "compound": {
416
+ if (args.length < 1)
417
+ return false;
418
+ const label = args[0];
419
+ const ts = args.slice(1);
420
+ if (!(v instanceof Tuple))
421
+ return false;
422
+ const lb = atomBytes(label);
423
+ if (lb === undefined)
424
+ return false;
425
+ const items = v.items();
426
+ if (items.length !== ts.length + 1)
427
+ return false;
428
+ const head0 = atomBytes(items[0]);
429
+ if (head0 === undefined || !bytesEqual(head0, lb))
430
+ return false;
431
+ for (let i = 0; i < ts.length; i += 1) {
432
+ if (!conformsIn(items[i + 1], ts[i], env, undefined, depth + 1))
433
+ return false;
434
+ }
435
+ return true;
436
+ }
437
+ // Structural collections — well-formedness delegates to ontos/data; the element
438
+ // descriptors add the per-element checks (descents).
439
+ case "list-of": {
440
+ if (args.length !== 1)
441
+ return false;
442
+ let elems;
443
+ try {
444
+ elems = readList(v);
445
+ }
446
+ catch {
447
+ return false;
448
+ }
449
+ for (const e of elems) {
450
+ if (!conformsIn(e, args[0], env, undefined, depth + 1))
451
+ return false;
452
+ }
453
+ return true;
454
+ }
455
+ case "set-of": {
456
+ if (args.length !== 1)
457
+ return false;
458
+ let elems;
459
+ try {
460
+ elems = readSet(v);
461
+ }
462
+ catch {
463
+ return false;
464
+ }
465
+ for (const e of elems) {
466
+ if (!conformsIn(e, args[0], env, undefined, depth + 1))
467
+ return false;
468
+ }
469
+ return true;
470
+ }
471
+ case "map-of": {
472
+ if (args.length !== 2)
473
+ return false;
474
+ let entries;
475
+ try {
476
+ entries = readMap(v);
477
+ }
478
+ catch {
479
+ return false;
480
+ }
481
+ for (const [k, val] of entries) {
482
+ if (!conformsIn(k, args[0], env, undefined, depth + 1) ||
483
+ !conformsIn(val, args[1], env, undefined, depth + 1)) {
484
+ return false;
485
+ }
486
+ }
487
+ return true;
488
+ }
489
+ // Records — a well-formed map keyed by utf8-text, with required/optional fields
490
+ // and a mandatory open/closed flag.
491
+ case "record": {
492
+ if (args.length < 1)
493
+ return false;
494
+ return conformsRecord(v, args[0], args.slice(1), env, depth);
495
+ }
496
+ // Composition — recurse on the SAME v, so the guard threads through unchanged.
497
+ case "union": {
498
+ for (const ct of args) {
499
+ if (conformsIn(v, ct, env, pending, depth + 1))
500
+ return true;
501
+ }
502
+ return false;
503
+ }
504
+ case "and": {
505
+ for (const ct of args) {
506
+ if (!conformsIn(v, ct, env, pending, depth + 1))
507
+ return false;
508
+ }
509
+ return true;
510
+ }
511
+ case "not": {
512
+ if (args.length !== 1)
513
+ return false;
514
+ return !conformsIn(v, args[0], env, pending, depth + 1);
515
+ }
516
+ // Singleton — membership by ontos structural identity (defines no new equality).
517
+ case "exact": {
518
+ if (args.length !== 1)
519
+ return false;
520
+ return exactEqual(v, args[0]);
521
+ }
522
+ // Recursion. rec binds the name; ref unfolds it, but only when guarded.
523
+ case "rec": {
524
+ if (args.length !== 2)
525
+ return false;
526
+ const nm = args[0];
527
+ if (!(nm instanceof Atom))
528
+ return false;
529
+ const nb = nm.bytes();
530
+ const body = args[1];
531
+ // Push one shared node onto each chain (no per-frame spread copy); the child
532
+ // frame sees this binding innermost-first.
533
+ const e = cons({ name: nb, body }, env);
534
+ const p = cons(nb, pending);
535
+ return conformsIn(v, body, e, p, depth + 1);
536
+ }
537
+ case "ref": {
538
+ if (args.length !== 1)
539
+ return false;
540
+ const nm = args[0];
541
+ if (!(nm instanceof Atom))
542
+ return false;
543
+ const nb = nm.bytes();
544
+ if (consContainsName(pending, nb))
545
+ return false; // unguarded at this node — would not terminate
546
+ // Innermost-first scan reproduces env.iter().rev(): an inner rec shadows an
547
+ // outer binding of the same name.
548
+ const body = lookupLast(env, nb);
549
+ if (body === undefined)
550
+ return false; // unbound reference
551
+ const p = cons(nb, pending);
552
+ return conformsIn(v, body, env, p, depth + 1);
553
+ }
554
+ // Unknown ctor or wrong arity → not a recognized descriptor.
555
+ default:
556
+ return false;
557
+ }
558
+ }
559
+ /**
560
+ * A field descriptor: `Tuple(Atom("horos/field"), utf8-text(name), Atom(req|opt), T)`.
561
+ * Returns `{ name, required, ty }` if `f` is a well-formed field, else `undefined`.
562
+ */
563
+ function readField(f) {
564
+ const h = head(f);
565
+ if (h === undefined || h.ctor !== "field")
566
+ return undefined;
567
+ const { args } = h;
568
+ if (args.length !== 3)
569
+ return undefined;
570
+ let name;
571
+ try {
572
+ name = readText(args[0]);
573
+ }
574
+ catch {
575
+ return undefined;
576
+ }
577
+ const presence = atomBytes(args[1]);
578
+ if (presence === undefined)
579
+ return undefined;
580
+ const p = bytesToAscii(presence);
581
+ let required;
582
+ if (p === "req")
583
+ required = true;
584
+ else if (p === "opt")
585
+ required = false;
586
+ else
587
+ return undefined;
588
+ return { name, required, ty: args[2] };
589
+ }
590
+ function conformsRecord(v, mode, fields, env, depth) {
591
+ // mode is a bare atom flag (open | closed).
592
+ const mb = atomBytes(mode);
593
+ if (mb === undefined)
594
+ return false;
595
+ let open;
596
+ const m = bytesToAscii(mb);
597
+ if (m === "open")
598
+ open = true;
599
+ else if (m === "closed")
600
+ open = false;
601
+ else
602
+ return false;
603
+ // v must be a well-formed ontos/data map.
604
+ let entries;
605
+ try {
606
+ entries = readMap(v);
607
+ }
608
+ catch {
609
+ return false;
610
+ }
611
+ // Index the map's utf8-text keys to their values in a single pass — a map-backed
612
+ // lookup replacing the old per-field linear `present.find`. `readMap` already
613
+ // guarantees keys are sorted and dup-free by codec bytes; distinct keys therefore
614
+ // decode to distinct text strings, so no `set` ever collides and the map preserves
615
+ // the Rust core's "first match in map order" exactly. A non-text key makes v not a
616
+ // record-shaped value (same early miss as the linear scan).
617
+ const present = new Map();
618
+ for (const [k, val] of entries) {
619
+ let key;
620
+ try {
621
+ key = readText(k);
622
+ }
623
+ catch {
624
+ return false;
625
+ }
626
+ present.set(key, val);
627
+ }
628
+ // Single pass over the declared fields drives BOTH checks (no second readField
629
+ // sweep): per field, look the name up in `present` (O(1)) for the required/optional
630
+ // verdict, and — in closed mode — count how many present keys a declared field
631
+ // covers. Field names are pairwise-distinct (`wf(record)`, checked at entry), so a
632
+ // present declared key is counted at most once.
633
+ let covered = 0;
634
+ for (const f of fields) {
635
+ const fld = readField(f);
636
+ if (fld === undefined)
637
+ return false; // malformed field descriptor
638
+ const val = present.get(fld.name);
639
+ if (val !== undefined) {
640
+ covered += 1;
641
+ if (!conformsIn(val, fld.ty, env, undefined, depth + 1))
642
+ return false;
643
+ }
644
+ else if (fld.required) {
645
+ return false;
646
+ }
647
+ }
648
+ // Closed: no present key outside the declared fields. With distinct field names,
649
+ // `covered` is exactly the number of present keys that match some declared field; a
650
+ // present key outside the declared set exists iff `covered < present.size`. This
651
+ // replaces the Rust core's second readField pass + O(fields×keys) sweep.
652
+ if (!open && covered !== present.size)
653
+ return false;
654
+ return true;
655
+ }
656
+ // ===========================================================================
657
+ // ===========================================================================
658
+ /**
659
+ * Whether `ctor` is a LEAF descriptor (a §4 leaf / §5 refinement / `exact`) — i.e. NOT a
660
+ */
661
+ export function isLeafCtor(ctor) {
662
+ switch (ctor) {
663
+ case "any":
664
+ case "atom":
665
+ case "atom-exact":
666
+ case "atom-len":
667
+ case "atom-in":
668
+ case "text-len":
669
+ case "key-grammar":
670
+ case "text-non-blank":
671
+ case "lowercase-uuid":
672
+ case "scalar":
673
+ case "exact":
674
+ return true;
675
+ default:
676
+ return false;
677
+ }
678
+ }
679
+ /**
680
+ * Decide a LEAF descriptor `t` against `v` (the `horos/leaf(T, v)` builtin). `false` for any
681
+ * non-leaf ctor. Reuses the recognizer's leaf arms via `conformsIn` at depth 0 with empty
682
+ * scopes — a leaf arm never recurses into a value position, so the scopes are inert.
683
+ */
684
+ export function leafClassifies(v, t) {
685
+ const h = head(t);
686
+ if (h === undefined || !isLeafCtor(h.ctor))
687
+ return false;
688
+ return conformsIn(v, t, undefined, undefined, 0);
689
+ }
690
+ /** Decide whether a LEAF descriptor `t` is well-formed (the `horos/wf-leaf(T)` builtin). */
691
+ export function leafWellFormed(t) {
692
+ const h = head(t);
693
+ if (h === undefined || !isLeafCtor(h.ctor))
694
+ return false;
695
+ return wfIn(t, undefined, undefined, 0);
696
+ }
697
+ /** Read a field descriptor for diagnostics. */
698
+ export function readFieldDescriptor(f) {
699
+ return readField(f);
700
+ }
701
+ // ===========================================================================
702
+ // well-formedness — wf(T)
703
+ // ===========================================================================
704
+ /**
705
+ * Decide `wf(T)` — "is `T` a well-formed horos descriptor?" Total and search-free;
706
+ * recurses into sub-descriptors (which are finite). A descriptor that fails `wf` is a
707
+ * valid ontos value, simply not a recognized descriptor; `conforms(_, T)` over such a
708
+ * `T` is `false`.
709
+ *
710
+ * For rec/ref: a `ref` must be BOUND by an enclosing `rec` and GUARDED — reachable
711
+ * only after a descent into the value. An unbound or unguarded `ref` (e.g.
712
+ * `rec(x, ref(x))` or `rec(x, union(ref(x), …))`) is not well-formed.
713
+ */
714
+ export function wf(t) {
715
+ return wfIn(t, undefined, undefined, 0);
716
+ }
717
+ function wfIn(t, bound, pending, depth) {
718
+ // Recursion-depth guard (horos-internal#37): mirrors conformsIn — past MAX_RECURSION_DEPTH frames a
719
+ // descriptor is treated as non-wf, deterministically and at the same depth in all three
720
+ // cores, so wf can never overflow the stack (RangeError) on a deep-but-finite descriptor.
721
+ if (depth > MAX_RECURSION_DEPTH)
722
+ return false;
723
+ const h = head(t);
724
+ if (h === undefined)
725
+ return false;
726
+ const { ctor, args } = h;
727
+ switch (ctor) {
728
+ case "any":
729
+ case "atom":
730
+ return args.length === 0;
731
+ case "atom-exact":
732
+ return args.length === 1 && args[0] instanceof Atom;
733
+ case "atom-len": {
734
+ if (args.length !== 2)
735
+ return false;
736
+ let lo;
737
+ let hi;
738
+ try {
739
+ lo = readInt(args[0]);
740
+ hi = readInt(args[1]);
741
+ }
742
+ catch {
743
+ return false;
744
+ }
745
+ // horos-internal#18 cap: a bound past i128 is non-wf, mirroring Rust's read_int (which returns
746
+ // IntOutOfRange). Then 0 <= lo <= hi.
747
+ if (!atomLenBoundInRange(lo) || !atomLenBoundInRange(hi))
748
+ return false;
749
+ return lo >= 0n && lo <= hi;
750
+ }
751
+ case "atom-in": {
752
+ if (args.length !== 1)
753
+ return false;
754
+ let elems;
755
+ try {
756
+ elems = readSet(args[0]);
757
+ }
758
+ catch {
759
+ return false;
760
+ }
761
+ // Every element must be an atom (an atom-membership set).
762
+ return elems.every((e) => e instanceof Atom);
763
+ }
764
+ // Text refinements (horos-internal#68). text-len carries char-count bounds with the same
765
+ // 0 <= lo <= hi + i128-cap discipline as atom-len (horos-internal#18); the three grammar-class
766
+ // refinements are nullary named predicates.
767
+ case "text-len": {
768
+ if (args.length !== 2)
769
+ return false;
770
+ let lo;
771
+ let hi;
772
+ try {
773
+ lo = readInt(args[0]);
774
+ hi = readInt(args[1]);
775
+ }
776
+ catch {
777
+ return false;
778
+ }
779
+ if (!atomLenBoundInRange(lo) || !atomLenBoundInRange(hi))
780
+ return false;
781
+ return lo >= 0n && lo <= hi;
782
+ }
783
+ case "key-grammar":
784
+ case "text-non-blank":
785
+ case "lowercase-uuid":
786
+ return args.length === 0;
787
+ case "scalar": {
788
+ if (args.length !== 1)
789
+ return false;
790
+ const k = atomBytes(args[0]);
791
+ if (k === undefined)
792
+ return false;
793
+ const kind = bytesToAscii(k);
794
+ return kind === "int" || kind === "utf8-text" || kind === "bool";
795
+ }
796
+ // Descent combinators clear pending (a value constructor is consumed).
797
+ case "tuple":
798
+ return args.every((ct) => wfIn(ct, bound, undefined, depth + 1));
799
+ case "seq":
800
+ return args.length === 1 && wfIn(args[0], bound, undefined, depth + 1);
801
+ case "compound": {
802
+ if (args.length < 1)
803
+ return false;
804
+ if (!(args[0] instanceof Atom))
805
+ return false;
806
+ return args.slice(1).every((ct) => wfIn(ct, bound, undefined, depth + 1));
807
+ }
808
+ case "list-of":
809
+ case "set-of":
810
+ return args.length === 1 && wfIn(args[0], bound, undefined, depth + 1);
811
+ case "map-of":
812
+ return (args.length === 2 &&
813
+ wfIn(args[0], bound, undefined, depth + 1) &&
814
+ wfIn(args[1], bound, undefined, depth + 1));
815
+ case "record": {
816
+ if (args.length < 1)
817
+ return false;
818
+ return wfRecord(args[0], args.slice(1), bound, depth);
819
+ }
820
+ // Same-v combinators thread the guard through.
821
+ case "union":
822
+ case "and":
823
+ return args.every((ct) => wfIn(ct, bound, pending, depth + 1));
824
+ case "not":
825
+ return args.length === 1 && wfIn(args[0], bound, pending, depth + 1);
826
+ case "exact":
827
+ // Any single ontos value is a valid singleton.
828
+ return args.length === 1;
829
+ case "rec": {
830
+ if (args.length !== 2)
831
+ return false;
832
+ const nm = args[0];
833
+ if (!(nm instanceof Atom))
834
+ return false;
835
+ const nb = nm.bytes();
836
+ // Shared push onto each chain (no per-frame spread copy).
837
+ const b = cons(nb, bound);
838
+ const p = cons(nb, pending);
839
+ return wfIn(args[1], b, p, depth + 1);
840
+ }
841
+ case "ref": {
842
+ if (args.length !== 1)
843
+ return false;
844
+ const nm = args[0];
845
+ if (!(nm instanceof Atom))
846
+ return false;
847
+ const nb = nm.bytes();
848
+ return consContainsName(bound, nb) && !consContainsName(pending, nb);
849
+ }
850
+ default:
851
+ return false;
852
+ }
853
+ }
854
+ function wfRecord(mode, fields, bound, depth) {
855
+ const mb = atomBytes(mode);
856
+ if (mb === undefined)
857
+ return false;
858
+ const m = bytesToAscii(mb);
859
+ if (m !== "open" && m !== "closed")
860
+ return false;
861
+ const names = [];
862
+ for (const f of fields) {
863
+ const fld = readField(f);
864
+ if (fld === undefined)
865
+ return false;
866
+ if (!wfIn(fld.ty, bound, undefined, depth + 1))
867
+ return false;
868
+ if (names.includes(fld.name))
869
+ return false; // duplicate field name
870
+ names.push(fld.name);
871
+ }
872
+ return true;
873
+ }
874
+ // ===========================================================================
875
+ // internal helpers
876
+ // ===========================================================================
877
+ // Literals under `exact` are data, not descriptor recursion. Compare their finite
878
+ // trees without consuming the JavaScript call stack or changing structural identity.
879
+ function exactEqual(left, right) {
880
+ const work = [[left, right]];
881
+ while (work.length > 0) {
882
+ const [a, b] = work.pop();
883
+ if (a.kind !== b.kind)
884
+ return false;
885
+ if (a.kind === "atom") {
886
+ if (b.kind !== "atom" || !bytesEqual(a.bytes(), b.bytes()))
887
+ return false;
888
+ }
889
+ else {
890
+ if (b.kind !== "tuple")
891
+ return false;
892
+ const aa = a.items(), bb = b.items();
893
+ if (aa.length !== bb.length)
894
+ return false;
895
+ for (let i = 0; i < aa.length; i++)
896
+ work.push([aa[i], bb[i]]);
897
+ }
898
+ }
899
+ return true;
900
+ }
901
+ /**
902
+ * Find the body bound to `name`, searching from the innermost (head) binding outward —
903
+ * the Rust core does `scope_iter(env).find_map(...)` (i.e. `env.iter().rev()`), so an
904
+ * inner `rec` shadows an outer binding of the same name. Returns `undefined` if unbound.
905
+ */
906
+ function lookupLast(env, name) {
907
+ for (let n = env; n !== undefined; n = n.tail) {
908
+ if (bytesEqual(n.head.name, name))
909
+ return n.head.body;
910
+ }
911
+ return undefined;
912
+ }
913
+ /**
914
+ * Decode bytes as a Latin-1/ASCII string for matching against the small set of fixed
915
+ * ASCII ctor/kind/flag literals. The Rust core matches raw byte slices (e.g. `b"int"`);
916
+ * decoding each byte to its code point reproduces that comparison exactly for ASCII and
917
+ * round-trips any byte 1:1 (no UTF-8 multi-byte folding), so a non-ASCII kind atom never
918
+ * spuriously matches a literal.
919
+ */
920
+ function bytesToAscii(bytes) {
921
+ let s = "";
922
+ for (const b of bytes)
923
+ s += String.fromCharCode(b);
924
+ return s;
925
+ }
926
+ /** Run `fn` and report whether it returned without throwing (the recognizer succeeded). */
927
+ function ok(fn) {
928
+ try {
929
+ fn();
930
+ return true;
931
+ }
932
+ catch {
933
+ return false;
934
+ }
935
+ }
936
+ //# sourceMappingURL=core.js.map