Repository navigation
featherduster - #1684
Closed
ssalbdivad wants to merge 565 commits into
Closed
featherduster#1684ssalbdivad wants to merge 565 commits into
ssalbdivad wants to merge 565 commits into
Conversation
In jitless mode, every valid call to a transforming root built a
Traversal and walked the interpreted transform pass, so a root as small
as `string.numeric.parse` ran 2.8x and `type("string").pipe(fn)` 15x
slower than 2.2.7, which called the morph directly after allows
("optimistic" and "branchedOptimistic").
A root whose transform doesn't require ctx, where each branch either
doesn't transform or is a morph whose input doesn't transform, now does
what 2.2.7 did: it takes the first branch that allows the data and calls
that branch's morphs in order. A non-union root is its own single
branch. The morphs of such a root take no ctx (a ctx-reading morph makes
the transform require ctx), so they are called with the value alone, as
the compiled transform calls them. A morph that returns an ArkError or
ArkErrors is finalized as the transform pass finalizes it: the data it
received is recorded and its errors are merged into a Traversal built
only then, so onFail and the messages are unchanged.
Branches are tried in order, as union Apply and 2.2.7 try them; a
discriminated union's branches are disjoint, so the first that allows is
its case. Compiled roots don't reach this path.
Measured against the parent (which has lazy errors), fresh processes,
interleaved, n = 12 rounds, jitless (median; wins; sign p):
- string.numeric.parse: 187.4 -> 109.3 ns (x0.583, 12/12, <.001)
- string |> fn: 94.6 -> 12.0 ns (x0.126, 12/12, <.001)
- string |> fn |> fn: 125.5 -> 29.3 ns (x0.234, 12/12, <.001)
- string.numeric.parse | number: 337.3 -> 70.9 ns (x0.210, 12/12, <.001)
- (string > 5 |> fn) | (string <= 5 |> fn): 366.2 -> 129.9 ns (x0.355,
12/12, <.001)
- discriminated union with a morph case: x0.951 (9/12, .146), n.s.
- invalid input, tuple unions with morphs, a union with a predicate
branch: x0.95-1.02, n.s. (not optimistic or failing first)
JIT rows: x0.91-1.11, none significant (compiled roots use
compileRootApply); moltar rows, paired in-process, n = 10: no change at
p < 0.05.
A jitless differential over morphs returning values, ArkError,
ArkErrors and empty ArkErrors, unions with a contextual predicate
branch, ordered and discriminated unions and a scope onFail gives
identical outputs, errors and call counts; the parity battery is
unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Delete mode checked a plain object for undeclared keys before building its output, so it could return the input when nothing was undeclared. The scan (a for...in with a switch on declared keys, then Object.getOwnPropertySymbols) cost more than building the output from the declared keys, which it had to do anyway whenever a key was undeclared. A plain object's output is now always built from its declared keys, as 2.2.7's delete mode always returned a new object: this restores 2.2.7's new-object output for plain objects and removes a 3.0 break (the parity battery's delete/nothing-to-delete case now matches 2.2.7 in both modes). An array still returns as is when nothing changed and nothing is undeclared, since it has to be scanned either way, and a class instance or null-prototype object returns as is when nothing is undeclared, as before; JIT and jitless agree on each (probe over plain, class, null-prototype, array and non-enumerable-key inputs). Measured against the parent: - moltar strip parse (extra keys), paired in-process, n = 20: 44.6 -> 20.0 ns (x0.448, 20/20, sign 1.9e-6) - fresh processes, n = 16: moltar shape with extra keys x0.859 (16/16), clean x0.296 (16/16), both <.001 - jitless clean input x1.453 (0/16): building a new object costs what returning the input saved; jitless with extra keys unchanged - other moltar rows, paired n = 20: none moved at p < 0.05 Test edit: transform.test "deletes them, ordering keys as its expression does" asserted that a clean input came back as the same object (`.is(original)`); it now asserts equal content (`.equals(original)`), since a new object is 2.2.7's behavior this change restores. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The reject scan tested each own key with `k in propsByKey`. propsByKey has a null prototype, so V8 keeps it in dictionary mode and each keyed `in` is a hash lookup (about 28 ns per key in a micro). It also collected Object.keys and Object.getOwnPropertySymbols into one array before checking any key, so data with an undeclared string key paid for the symbol read before it was rejected. String keys now go through a switch whose cases are the declared string keys (`case "a": case "b": continue`), the switch delete mode already emits, now shared as compileDeclaredKeySwitch. What follows the switch rejects unless an index signature or the sequence declares the key, and rejects unconditionally when neither exists. Symbol keys are scanned in a loop of their own afterward, with a switch on the declared symbol keys, so the errors keep their order (string keys, then symbols) and an undeclared string key rejects before symbols are read. With an index, its values are still traversed for every matching key before the switch, so `continue` skips only the reject. Object.keys stays rather than for...in plus Object.hasOwn: hasOwn is ES2022, which 2.2.7 never required, and the gain measured for for...in came from the split symbol loop, which this keeps. Measured against the parent, paired in-process, n = 20: - moltar strict allows: 214.2 -> 135.3 ns (x0.632, 20/20, sign 1.9e-6) - moltar strict parse: 220.7 -> 135.9 ns (x0.616, 20/20, 1.9e-6) - moltar strict, undeclared key, allows: 106.1 -> 26.5 ns (x0.250, 20/20, 1.9e-6) - moltar strict, undeclared key, errors: x0.938 (18/20, 4.0e-4) - index signature allows x0.948 (14/20, .12), parse x1.063 (11/20, .82), n.s. - other moltar rows: none moved at p < 0.05 Results, errors, their order and paths are unchanged (differential over declared, undeclared, symbol, inherited, non-enumerable, index and sequence keys and nested reject; the interpreted path is untouched); the parity battery is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ArkError.propString called stringifyPath on every read, and add() and the message writers each read it; ReadonlyPath.stringify caches the same string. Measured against the parent, paired in-process, n = 16 (median ns; wins; sign p): - moltar invalid errors: 13271 -> 12321 (x0.928, 15/16, 5.2e-4) - moltar strict, undeclared key, errors: x0.954 (16/16, 3.1e-5) - product invalid errors: x0.818 (16/16, 3.1e-5) - items invalid errors: x0.912 (15/16, 5.2e-4) - union items invalid errors: x0.946 (15/16, 5.2e-4) - patterns and strings invalid errors, other moltar rows: none moved at p < 0.05 Error output is unchanged (differential over 43 error cases); the parity battery is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Each node of a compiled unit held the unit's source as a string
(`precompilation`), written by CompiledFunction.write. V8 already keeps
that source for the function it compiled from it, so every live type
held its unit's source twice.
A node (and a scope) now holds the compiled unit function, and
`precompilation` is a getter returning that function's source. Code that
checked `precompilation` to tell whether a node was compiled checks
`compiledUnit`, which doesn't build the string.
Memory per live type (1000 distinct types each called once, heap after
gc, 4 runs each, spread under 0.1 KB):
- moltar shape: 34.15 -> 28.11 KB (-17.7%)
- discriminated union: 41.29 -> 36.70 KB (-11.1%)
- number range: 25.21 -> 24.01 KB (-4.8%)
Creation, fresh processes, n = 12: x0.965-0.969 (6-7/12), not
significant. Moltar rows, paired in-process, n = 12: none moved at
p < 0.05.
The precompilation text is now Function#toString's
(`function anonymous(dependencies,refs,errorContexts\n) {...}` instead
of `function $(dependencies, refs, errorContexts) { ... }`), an
implementation detail; tests only check that it's a string, and
undefined when jitless. BaseNode/BaseScope.precompilation are declared
as getters, with a `compiledUnit` field beside them.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A root union without a compiled discriminant whose transform doesn't require ctx ran its Allows, then its transform, which runs each branch's Allows again to find the one to transform. The taken branch was validated twice, so a predicate in it ran twice for valid data (2.2.7 ran it once). Its transform now returns `unset` (2.2.7's sentinel for the same case, from traverseOptimistic) when no branch allows the data, and the root calls the transform directly, falling back to Apply's errors only on `unset`. Every other caller of the transform has already checked the union's Allows, which here reads no ctx, so a branch allows there and the sentinel is never returned to it. A union whose transform requires ctx keeps returning data and its root keeps checking Allows first. In jitless mode the same holds for the interpreted transform, for roots the optimistic path doesn't cover (a branch transforming more than its morphs). Measured against the parent, fresh processes, interleaved: n = 16 rounds (median; wins; sign p): - [string.numeric.parse] | [string, string], JIT: 85.7 -> 58.0 ns (x0.677, 16/16, <.001); jitless 858 -> 569 ns (x0.663, 16/16, <.001) - [string.numeric.parse] | [string, narrowed string], JIT: 54.8 -> 44.8 ns (x0.818, 16/16, <.001); jitless 843 -> 526 ns (x0.624, 16/16, <.001) - discriminated union with a morph case, JIT: x0.894 (13/16, .021), which this doesn't compile differently - unions of morphs and of length-constrained branches: x0.97-1.01, n.s. The moltar types' units and root applies compile to identical source. A predicate in the taken branch now runs once per valid call in both modes (was twice); outputs and errors are identical over valid, invalid and morphed inputs to ordered, discriminated, nested and contextual unions (probe, JIT and jitless), and the parity battery is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
V8 takes a slow generic path for `x instanceof C` while C's class has a static accessor keyed by a symbol (here `static get [Symbol.species]`), until enough instanceof checks have run. Parsing and checking the result with `instanceof type.errors` is the documented pattern, and `~standard.validate` does the same check. The species is now a static field holding Array, so inherited array methods still return a plain Array. The property becomes a data property instead of an accessor; 2.2.7 had the accessor too. Measured with and without it, fresh processes (one row per process, as the effect is in a process's first checks), interleaved: n = 20 rounds (median ns; wins; sign p): - string parse, then instanceof type.errors: 24.2 -> 4.1 (x0.168, 20/20, 1.9e-6) - moltar parse, then instanceof: 39.9 -> 19.3 (x0.483, 20/20, 1.9e-6) - constraints parse, then instanceof: 134.2 -> 107.7 (x0.802, 16/20, .012) - moltar ~standard.validate: 49.9 -> 28.7 (x0.574, 20/20, 1.9e-6) - date ~standard.validate: 46.7 -> 25.6 (x0.548, 19/20, 4.0e-5) - moltar invalid errors: x1.046 (12/20, .50), n.s. `ArkErrors[Symbol.species]` is still Array, and map/filter/slice on an ArkErrors still return plain arrays (tested); the parity battery is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ArkError's constructor copied its input with an object rest that omits prefixPath and relativePath. V8 runs a rest with excluded keys through a runtime call, while a spread takes its clone fast path; the input of most errors has neither key. The rest is kept for an input with either key, and any other input is copied by spread. Both copy the input's own enumerable props, string and symbol, so `input` and the error's own props are what they were. Measured without and with it, paired in-process, n = 16 (median ns; wins; sign p): - moltar invalid errors: 13346 -> 12916 (x0.968, 14/16, 4.2e-3) - items invalid errors: 17032 -> 15749 (x0.925, 15/16, 5.2e-4) - union items, strings and moltar strict invalid errors: x0.97-0.98 (11-12/16), n.s. - product invalid errors x1.023 (5/16, .21), patterns x1.023 (8/16), n.s.; no moltar row moved at p < 0.05 Error output is unchanged (differential over 43 error cases, including inputs with prefixPath or relativePath and symbol paths); the parity battery is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The default problem writer read `ctx.actual` twice and the union message read `ctx.problem` twice. Each read calls the configured writer again: for a 10001-character pattern input that is two JSON.stringify calls, and for a union it builds the union's expected (every branch's actual and expected) twice. They now destructure what they read, as the unit problem writer already does. Messages are identical; a custom actual or problem writer is called once where it was called twice, which only side effects could observe. Measured without and with it, paired in-process, n = 16 (median ns; wins; sign p): - moltar invalid errors: 10191 -> 8810 (x0.865, 16/16, 3.1e-5) - items invalid errors: 11229 -> 9465 (x0.843, 16/16, 3.1e-5) - union items invalid errors: 12519 -> 10471 (x0.836, 16/16, 3.1e-5) - patterns invalid errors: 72942 -> 64132 (x0.879, 16/16, 3.1e-5) - product and strings invalid errors, moltar rows: none moved at p < 0.05 Messages are identical (differential over 43 error cases, including configured writers); the parity battery is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…h one
A structure with an index signature read Object.getOwnPropertySymbols
for every object it checked or transformed, a C++ call, even when every
signature extends string and so can't match a symbol. The symbols were
read only to be skipped.
Symbol keys are now read only when undeclared keys are rejected or some
signature doesn't extend string. A signature that extends string never
declares a symbol key, so neither the traversal nor delete mode's copy
of declared keys sees one.
Measured without and with it, paired in-process, n = 20 (median ns;
wins; sign p):
- { a: string, "[string]": string | number } allows: 129.9 -> 85.6
(x0.659, 20/20, 1.9e-6)
- the same, parse: 139.2 -> 88.9 (x0.638, 20/20, 1.9e-6)
- moltar rows: none moved at p < 0.05
Results and errors are unchanged over string, pattern, symbol and
string | symbol signatures, plain and morphed, with reject and delete
(differential, JIT and jitless); the parity battery is unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ReadonlyPath cached stringify and stringifyAncestors separately, and an error's path computed both: ArkErrors.add reads the error's propString (stringify) and then indexes each of its ancestors (stringifyAncestors), so every key of every error path was stringified twice, each pass running isDotAccessible's regex over string keys. stringify now returns the last of the cached ancestors, which is the whole path, so one pass serves both. Measured without and with it, paired in-process, n = 16 (median ns; wins; sign p): - moltar invalid errors: 7958 -> 7068 (x0.888, 16/16, 3.1e-5) - moltar strict, undeclared key, errors: 6086 -> 5369 (x0.882, 16/16, 3.1e-5) - product invalid errors: 71823 -> 57670 (x0.803, 16/16, 3.1e-5) - items invalid errors: x0.920 (15/16, 5.2e-4) - union items invalid errors: x0.936 (16/16, 3.1e-5) - strings invalid errors: x0.969 (15/16, 5.2e-4) - patterns invalid errors x0.950 (8/16), moltar rows: none moved at p < 0.05 Error output is unchanged (differential over 43 error cases); the parity battery is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The memo of intersection and pipe results was a module-level record
keyed by the operands' hashes. Equal nodes from two scopes share a hash,
so a scope intersecting { x: string } & { y: number } after another
scope had got the other scope's result: its type's $ was the other scope
and it wrote errors with that scope's configured messages (bugs.md #3,
in 2.2.7 too). Every result also stayed reachable from the record, so
dropped types were never collected, and each lookup built a string from
both operands' full hashes.
The memo, and the one kept apart for aliases until resolution is final,
is now a WeakMap per operator from l to a WeakMap from r to the result.
A scope's nodes are its own objects, so a lookup only finds a result
computed from those same operands, and an entry is collected with its
operands. Two equal operands from different scopes now intersect
separately; each intersection still runs once per pair of operands.
Test: scope.test "cross-scope intersection" (the bug's repro; it fails
without this).
Measured without and with it, fresh processes, interleaved:
- heap retained per type after creating and dropping 1,000 types
(n = 6, 6/6 each, sign .031, MWU .004): A.and(B) 21.2 -> 9.6 KB
(x0.453); a 4-alias scope with an intersection 74.8 -> 16.1 KB
(x0.215); a discriminated union x0.939; number & number > i x0.970
- creation, n = 12: a 40-branch discriminated union 53.0 -> 44.5 ms
(x0.840, 12/12, 4.9e-4); objects, intersections, unions, index
signatures, extends and scopes x0.86-1.02, n.s.
Validation code is unchanged: the moltar, product, union and index
types' units and root applies compile to identical source. The parity
battery and the 43-case error differential are unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
05f35af moved the names `./internal/*` deep imports need off the main entry, so a consumer's `export * from "arktype"` beside `export * from "zod"` links again. It built out/internal.js as a second entry of the same splitting build, which moves the main entry's code into a chunk shared by both: a root import of arktype, @ark/schema, arkregex and now @ark/util (its get module adds a name the main entry lacks) reads one more file per package. That commit measured the cost at 1.080x and named this alternative without building it. The main entry is built alone and whole again. Each name a module exports that the main entry lacks is exported from it under an alias, `${name}$${module}` as for an ambiguous name already (`get$get`, `InternalScope$scope`), and out/internal.js is a written file that re-exports the main entry and maps each alias back to its name. A module whose name internal.js binds to another module's still gets an own file mapping its alias back. No package's main entry has a chunk now apart from the one its config entry shares. The aliases are runtime names on each root namespace that 2.2.7 didn't have: arktype 21 -> 155 names, arkregex 1 -> 19, @ark/schema 196 -> 199, @ark/util 128 -> 129. Types are unchanged, and none of the names a barrel collided on (string, number, object, url, capitalize) is among them: the zod and @ark/util barrels link and read the other package's values, and `"string" in arktype` stays false. Cold `import "arktype"`, fresh processes, ABBA, n = 100, load ~17: 49.65 -> 45.47 ms median (0.916x, aliased faster in 67/100, sign p .0009; A/A 1.013x, p .09). Importing out/internal.js: 1.021x (36/60, p .16), no change detected. esbuild min/gzip of an app using all of arktype's root: 196,368/60,366 -> 196,365/60,346; a minimal app 196,178/60,269 -> 196,174/60,248. The bundle test's root now lists the aliases, and a root import evaluates the module only an internal name needs, as before 05f35af. Battery: identical to the previous commit (300/300). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…n one place invokeTransform in union.ts spelled out `branch.includesMorph && !branch.transformRequiresContext`, the test compile.ts already names returnsTransformErrors for transformKey. compile.ts now exports it and union.ts calls it, so the rule for which transforms return their errors rather than add them to ctx lives once. @ark/schema exports the one new name, as it does compile.ts's other helpers. Emitted code is identical for every validate.bench row. Battery: identical to the previous commit (300/300). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
transformKey took `node: BaseNode | readonly TransformStep[]` and
wrapped a lone node as a step, so its `node` param could hold a list,
and the index loop's local transformKey copied the overload. Every
other NodeCompiler method's `node` is a node (invoke, check,
traverseKey), as in 2.2.7. It now takes `steps`, and the four callers
with one node (a morph's input, a sequence's elements, a structure's
sequence and its single transforming index signature) pass `[{ node }]`.
Emitted code is identical for every validate.bench row.
Battery: identical to the previous commit (300/300).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The comment above BaseNode's transformSelectsByContext described UnionNode's and IndexNode's overrides, which carry comments of their own, rather than the inherited rule beneath it: a node passes ctx down when a child picks what it transforms by ctx. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
10450dd glossed TransformStep's `signature` and the branch in transformsOf that defers a signature reading ctx. The name, the allowsRequiresContext test and the step's use as an Allows condition say as much, and both files already sit above 2.2.7's comment density (compile.ts had none). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The variadic key passed the sequence's parse context on as options, so a variadic element that wasn't already cached was created with the sequence's id; the prefix, optional, defaultable and postfix elements are parsed without it. Wherever the sequence node kept that id too, the two compiled to the same names and the element's check replaced the sequence's, so the array itself was checked against the element: - without arksets, every array @ark/schema parsed, since ecf3ea9 stopped caching nodes parsed without the engine (an element used to come back from the cache under its own id): `rootSchema({ proto: Array, sequence: "number" })` rejected `[1]` with "must be a number (was an object)", where 69288d0 accepted it; - with the engine, and in 2.2.7, wherever its sequence reduction leaves the node as parsed, which an explicit minVariadicLength it keeps does: `rootSchema({ proto: Array, sequence: { variadic: { unit: 12345 }, minVariadicLength: 1 } })` rejects `[12345]` on both. The element now gets its own id, as its siblings do. The context's other options were never meant for it: the sequence's own callers pass none, or `prereduced` with elements that are already nodes. arktype passes its elements as nodes, which are returned before an id is assigned, so its arrays were never affected. A 2.2.7 bug fixed (the minVariadicLength case); its one line of 2.2.7 code, `ctx.$.parseSchema(schema, ctx)`, loses the forwarded ctx. Test: parse.test.ts "variadic element with minVariadicLength" (a fresh scope, so the element isn't cached), which fails at the parent. Battery: identical to the parent (300/300). No change to emitted code for any node arktype builds, so validation isn't measured; parse registers one id per uncached raw variadic element. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…engine 69288d0 refused, without arksets, a sequence with anything but a variadic element, on the grounds that it implies a length bound the engine would add to its intersection. Optional and defaultable elements before a variadic imply none (impliedSiblings is empty unless minLength is positive or there is no variadic), so `rootSchema({ proto: Array, sequence: { optionals: ["string"], variadic: "number" } })` and the same with `defaultables: [["string", "d"]]` threw missingSetEngineMessage although they validate the same with and without the engine, as they did at 1017a2d. The refusal now follows impliedSiblings: no variadic (a maximum length), or a prefix, postfix or minVariadicLength (a minimum length). Without the engine, `{ optionals: [] }` with no variadic now throws missingSetEngineMessage before reaching the TypeError ("Cannot read properties of undefined (reading 'nestableExpression')") it throws with the engine and in 2.2.7. A break avoided: these parsed in 2.2.7's @ark/schema, which held the algebra. The README names the refused case as a tuple that bounds its length. Test: engine.test.ts "parsing a tuple with no length bound does not", which throws at the parent. Probe (/tmp/fd/rv-p1sc/seqfix.ts, ten sequence shapes by six inputs, jit and jitless): each one parsed without the engine either is refused or matches the engine's results. Battery (engine installed): identical to the parent (300/300). One property read per engine-less sequence parse; not measured. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
037e4e7 deleted holdsNodesWeakly, the only argument ever passed to WeakCache's `weak` parameter, and left the parameter because ark/util was outside that lane. Every WeakCache is now constructed without it, so it holds values weakly wherever WeakRef and FinalizationRegistry exist and pins them otherwise, as each one already did. No behavior change; WeakCache is new on the branch, so nothing 2.2.7 exposed changes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…irst testSchemaFirst has failed since a19f4e4, so `pnpm testRepo` fails on 3.0: `type("object.json")({ a: 1n })` reads "a must be a number, a string, an object, boolean or null (was a bigint)" where the test, and 2.2.7, expect "a must be an object (was a bigint)". It isn't specific to parsing with @ark/schema first; plain arktype gives the same, and the same at `a.b`. 2.2.7's message came from #1026: jsonData's `$jsonObject` branch stayed an alias, so its discriminant routed every domain it didn't list, bigint included, to that branch as the default case and reported only "an object". a19f4e4 rebuilds the union once the alias resolves, so it is discriminated on all its domains. 2.2.7 itself gives 3.0's message for the same union written as a scope, `scope({ j: "number | string | boolean | null | o", o: { "[string]": "j" } }).export().o({ a: 1n })`. A break required by correctness (the old message omits four of the five domains the value may have). The assertion is updated to it; the test's other two checks still tell an intrinsic parsed with the engine from one parsed without it. The break is added to E-breaking for David to confirm. Gates: testIntegration passes (all five scripts). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
57d5e78 reads symbol keys only when some index signature doesn't extend string, which it decides with `extends`, and `extends` needs the set engine. Without arksets, every structure with an index signature threw missingSetEngineMessage as it compiled, so `rootSchema({ domain: "object", index: { signature: "string", value: "number" } })` couldn't validate anything, though a string, symbol or `string | symbol` signature parses without the engine. That includes the intrinsic jsonObject, so testSchemaFirst, which parses with @ark/schema before arksets is installed, threw it. Without the engine, the compiled structure reads symbol keys again, as it did before 57d5e78 and as jitless always does; the traversal skips any a signature doesn't match. With the engine, the getter and the emitted code are unchanged. Test: engine.test.ts "parsing an index signature does not", which throws at the parent. Probe: string, symbol and string | symbol signatures give the same allows results without and with the engine. Battery (engine installed): identical to the parent (300/300). The change is a property read per signature at compile time; validation code is unchanged, so it isn't measured. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
6743f7f changed benchErrorOnThresholdExceeded's default from true to "types", so a consumer's runtime bench that exceeded its baseline logged 📈 and exited 0 where @ark/attest 0.56.7 (arktype 2.2.7) exited 1 with a ❌ summary. Its reason, that runtime medians are too noisy to fail on, is about this repo's own benches, and 2.2.7 already handled that per script. Restoring the default avoids the break. The repo keeps today's behavior through its scripts: - `bench` gets back 2.2.7's ATTEST_benchErrorOnThresholdExceeded prefix. Every bench it runs is a type bench, where true and "types" behave the same, so it changes nothing; it only restores 2.2.7's line. - `benchRuntime` passes `--benchErrorOnThresholdExceeded types` to each of its three files, since an env prefix reaches only the first command of an && chain. The README's default block says true again, and its paragraph says what the code does: a bench fails over the threshold, and "types" fails only type benches. Its example passes a value, since 2.2.7's bare `--benchErrorOnThresholdExceeded` took the next flag as one. Checked with a runtime bench whose baseline is 0.0001 ns, run from source: by default two ❌ lines and exit 1, as in 2.2.7; with `--benchErrorOnThresholdExceeded types` or 2.2.7's env prefix, 📈 only and exit 0, as `pnpm benchRuntime` behaves now. Battery: not affected (attest and scripts only). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
99bc817 needed a condition for its new bundle() call and also rewrote 2.2.7's dtsGen line to use it. That line sits in the else of `packageName === "arktype"`, where the new const reduced to 2.2.7's `packageName in packagesByScope.type.json.dependencies!`, so the rewrite changed nothing. The condition is now inline on bundle()'s branch, its only remaining use, and the dtsGen line is 2.2.7's again. Adding arksets also sorted the dependencies around it: - arktype's package.json put @ark/schema before @ark/util; - ark/repo's put arkregex and arksets before arktype. Nothing reads key order (build.ts uses `in`, and pnpm builds in topological order), so both keep 2.2.7's order with the additions after it. These restore 2.2.7 code. The same packages are bundled and get dtsGen; `pnpm build`, testBuild and testBundle pass. Battery: not affected (build scripts and package.json order only). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ecbecf7's exact `./internal/config` and `./internal/keywords/ts` entries in arktype's and @ark/schema's package.json carried a "types" condition pointing at the .d.ts beside their "default" .js. TypeScript finds that .d.ts from "default" on its own, which is how 2.2.7's "./config" entry works ({ "ark-ts", "default" } only), so the six entries now take 2.2.7's shape. The wildcard entries keep "types", since their "default" is index.js. assertMapsToOwnFile's message, which tells a maintainer what entry to write, drops it too, and bundle.test.ts's assertion of that message follows. That is the one test edit: it pins the build script's own error text, which this commit changes by design. A consumer importing all six specifiers resolves each to the same out/*.d.ts with and without the condition under TS 5.9 NodeNext and Bundler (--traceResolution), with identical diagnostics. No published type changes; `pnpm build`, testBuild, testBundle, tsc and the 1893 tests pass. Battery: identical to the previous commit (300/300). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
haveSameMap wrapped the one %HaveSameMap call, while the same loop calls %HasFastProperties through eval inline, as 2.2.7's testV8.js does. A direct eval reads the loop's locals, so the call now sits where it is used. `pnpm testV8` passes (17 node kinds, one map each), and a probe that adds a slot to one unit node still fails with "unit nodes false and true have different maps after construction". Battery: not affected (test script only). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
packageOf makes a temp dir and writes a package into it, but an `xOf` in 2.2.7 is a pure derivation (domainOf, keysOf, precedenceOfKind), and 2.2.7 names its file writers writeFile, writeJson and writeApiDataFile. It is writePackage now. evaluated() bound globalThis to a local named `global`, shadowing Node's. It now drains the trace array with splice(0), as afterEach drains `packages`, so it needs no local: the next traced module pushes into the emptied array through its `??=`. Once a traced module has run, evaluated() returns [] rather than undefined when nothing new evaluated, so the one assertion of that case (importing internal.js evaluates nothing) reads `.equals([])`. That is the only assertion edit, and it pins the helper's own return, which this commit changes. afterEach stays, as in 2.2.7's one fs-writing test (attest/__tests__/externalSnapshots.test.ts). Battery: not affected (test only). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`until: { count: 20 }` was commented as capping how many types a bench
makes, and effc497's body said the same. attest's ResultCollector
warms up for min(500 ms, until.ms) whatever `count` is, and `count`
stops only the sampling that follows, as `ms` does. A bench whose call
takes 0.86 ms made 297 calls under count 20 (load ~20), against about
6,000 for the default 5 s of samples. So the cap bounds the samples,
and through them most of the types a bench retains; the comment now
says that.
Counting warm runs toward `count` instead would make `count` and `ms`
bound different phases and cut the warmup of every low-count bench,
and the create medians were baselined with this warmup (c508faf).
Battery: not affected (comment only).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
6743f7f made two changes so `ATTEST_filter=moltar` would select every row of a bench scenario: - a string `filter` kept a bench when any path segment started with it, where @ark/attest 0.56.7 (arktype 2.2.7) required a segment equal to it, so an existing filter like "ab" also ran "abc" and "ab (zod)"; - parseEnvValue read an ATTEST_ value that is not JSON as a plain string. That also turned a typo like ATTEST_benchPercentThreshold=1O into the string "1O", where 2.2.7 threw. baseline.ts then compares `delta > "1O"`, which is always false, so the threshold silently stopped failing anything. Nothing in the repo passes a prefix filter, and neither change was needed for correctness or perf, so both go back to 2.2.7's code: benchFn's filter block and addEnvConfig are 2.2.7's verbatim, which avoids the break in filter selection and restores the throw on a malformed env value. The README drops the sentence that documented the prefix match; 2.2.7 had none. A scenario's rows can still be run one at a time with the exact name, e.g. ATTEST_filter='"moltar allows (arktype)"'. Checked from source with benches "ab", "abc" and "ab (zod)": ATTEST_filter='"ab"' runs only "ab", ATTEST_filter='["abc"]' only "abc", and ATTEST_benchPercentThreshold=1O and ATTEST_filter=ab each throw a JSON SyntaxError, all as in 2.2.7. Battery: not affected (attest only). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
6743f7f stored each sample's result in a module-level benchSink, which warnIfUnused read as a global, on the reasoning that a module variable is what keeps V8 from dropping the calls whose result a bench returns. A local that warnIfUnused reads after the loop is just as observable, and the generated loop itself keeps only its last call's result either way, so the module variable protected nothing a local does not. loopCalls and loopAsyncCalls now keep their result local, as 2.2.7's loops kept all their state, and warnIfUnused takes it as a parameter. Measured with allocating benches, since a boolean has no allocation for V8 to remove: this commit's attest (B) against its parent (A), fresh process per sample, ABBA, n=8 per arm, until 2 s, load 40-47: - object literal: 7.36 → 7.23 ns, x0.982, p 0.63 - two-element array: 13.89 → 14.07 ns, x0.993, p 0.73 - object spread: 19.71 → 19.59 ns, x0.987, p 0.46 No difference, and at ~7 ns the object literal is still allocated in both arms. The review's earlier run (n=16, load 12-17) agrees: x1.036 p 0.055, x0.986 p 0.65, x0.994 p 0.79. A bench returning undefined still warns, sync or async. Battery: not affected (attest only). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
compileExhaustiveEntry took the props to switch on, and its caller declared k, so the string-key and symbol-key loops could share it. Since e3e996e the symbol loop calls compileIndexEntry, so its one caller always passed the string-keyed props. It is back to 2.2.7's signature and declares k itself, filtering the string-keyed props where the reject switch needs them. breakIfUndeclared keeps its props parameter, since the delete transform still calls it from both loops. The emitted code is unchanged: the precompilation of 15 types (MoltarStrict and six reject, index and symbol-prop combinations among them) is byte-identical, 3,842 lines, and so is the parity battery (300/300). The schema declaration bundle regains 2.2.7's `compileExhaustiveEntry(js: NodeCompiler): NodeCompiler`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every validate row calls one type from its own site, so the site's feedback sees a single function. A framework validating each route's input through one shared `schema(data)` call sees many: that is the path a change to how a Type is called (36a5ff2's apply holder, or whether the node is a bound function) can slow without any existing row moving, and until now it was only measured in side harnesses. "rotating parse" calls eight types round robin from one site: string, a length-bounded string, two single-key objects, number, string[], a two-branch union and a range, so the site sees domain, intersection, union and structure roots. Each datum is valid for its type. zod and valibot get the same rows, as every scenario does, and benchScenarios.test checks that all three accept each datum alike. Baselines: arktype is the median of 10 attest runs at the parent (384.91 ns, 360-416); zod 1.63 us and valibot 1.96 us are single runs. Gates: tsc, test (2085 passing), prettier and eslint on the three files. No package source changed, so the build output is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Callable returned `fn.bind(this)` with the class prototype set on it,
so every Type was a bound function wrapping BaseNode's arrow. The arrow
reads `this` lexically, so the binding did nothing but add a target:
each node's bound function is a distinct callee, and a site that calls
many types (a framework validating every route through one
`schema(data)` call) goes megamorphic. Every node's arrow shares one
function literal, so the same site called with the arrows themselves
sees a single target and TurboFan inlines it there
(--trace-turbo-inlining on the round robin below: one candidate, the
arrow, inlined into the calling loop; none at the parent).
CallableOptions.bind now accepts null, which uses fn as is, and
BaseNode passes it. fn is the arrow BaseNode creates for that node, so
setting its prototype and assigning its attachments is safe.
Visible changes (David took these, 2026-10-06 06:55):
- `T.name` is "" (was "bound ")
- `Function.prototype.toString.call(T)` returns the arrow's source
(was "function () { [native code] }")
- a stack frame through a Type has no name (was
`DomainNode.<anonymous>`)
`T.toString()` ("Type<string>") and `T.length` (2) are unchanged; the
new type.test "is an unbound function" pins all four, jit and jitless.
The parity battery is identical (300/300 lines, md5 ca4828f6).
Measured against the parent, C1's harness (fresh processes, arms
interleaved and reversed every other round, 64-input pool, checked
sink), 200 other types created and called first, n = 20, load 8-36:
- 8 types round robin from one site: parse 43.98 -> 29.39 ns (x0.668,
20/20, Mann-Whitney p 3.9e-7); allows, which doesn't go through the
arrow, x0.965 (11/20, p .92)
- moltar parse x0.747 (13/20, p .041) and defaults parse x0.797
(16/20, p .005). Not claimed: under this load C1 saw monomorphic
gains that did not reproduce at load 4-9 (moltar x0.61 vs x0.98).
- moltar allows x0.842, moltar errors x0.956, string x1.240 (12/20
faster), date x1.040, discriminated x0.913, morph x1.050,
constraints x0.930, narrow x0.998, items x0.941: all p >= .086.
No row is slower at p < .05.
At load 4-9 C1 measured the round robin 26.2 -> 14.4 ns (x0.55, 20/20,
p 2.6e-7), and with one type alive 39.3 -> 20.4 ns (x0.52, p 3.3e-5).
The new "rotating parse (arktype)" row, 10 attest runs per arm
interleaved, load 10.5-12.5: 384.91 -> 266.42 ns (x0.692, 10/10, exact
Mann-Whitney p 1.1e-5); its baseline moves to that median.
Gates: build, tsc, test (2086 passing), testV8, testBuild,
testIntegration, attest (63 passing), prettier and eslint on the
changed files. The build regenerated the docs' util declarations for
the widened option.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Deep imports (`arktype/internal/parser/string.ts`) resolved at runtime
to an out/internal.js that bundle.ts generated: it linked every module
through esbuild, found the names the main entry lacked, exported each
from out/index.js under an alias (`parseString$parser$string`) and
mapped the aliases back, with own files for names two modules share.
Every root namespace carried those aliases (arktype 154 runtime names
where 2.2.7 has 21, arkregex 19 where it has 1), and the clash handling
was most of bundle.ts.
Instead, each package gets one typed `<pkg>/internal` entry, written
like 2.2.7's ark/schema/index.ts, and the old paths keep working as
aliases of it.
- ark/{util,schema,regex,type}/internal.ts re-export index.ts, then
each module index.ts lacks, then pick each name two modules export:
arktype's Comparator (attributes), json (keywords/string), object
(keywords/ts) and InfixExpression (parser/ast/infer); @ark/util's
`get` (get.ts, not generics' type). arksets' index already lists
every module, so its ./internal is its index.
- Each package.json gains "./internal". "./internal/*.{ts,js}" still
load out/internal.js at runtime (arksets: out/index.js); the exact
internal/config.* and arktype internal/keywords/ts.* entries stay, so
configure() can still precede the package and that module's `json`
is still its own.
- bundle.ts builds every non-wildcard subpath's file as an entry of one
splitting build, so index and internal share a module instance, and
only keeps renaming self-referencing classes. flattenIntoInternal,
assertMapsToOwnFile, exportedNamesOf, the alias writers and their
tests are gone. A file several subpaths export is one entry: esbuild
given it twice moves its code into a chunk.
Two details differ from the proposed shape:
- Deep imports keep per-module types ("types": "./out/*.d.ts"). Typed
from out/internal.d.ts, a declaration in out/variants/object.d.ts has
no subpath TypeScript can name, so a consumer emitting declarations
fails: `export const T = type({ a: "string" })` with declaration:
true gives TS2742 "The inferred type of 'T' cannot be named without
a reference to './node_modules/arktype/out/variants/object.js'", and
@ark/json-schema's own build failed so. Per-module types also keep a
2.2.7 import of a name another module shares compiling
(InfixExpression from parser/tupleExpressions.ts). At runtime every
such path still gets every name.
- internal.ts reaches index's modules through index.ts (an
eslint-disable, as 2.2.7's type/config.ts) rather than re-listing
them. Listing schema's modules one by one gave the chunk shared with
index an evaluation order that broke both: import("@ark/schema")
threw "Cannot read properties of undefined (reading 'name')" from a
scope built before resolvedConfig, though that order runs from
source. Through index.ts, @ark/schema's root evaluates its modules in
exactly the parent build's order; @ark/util's and arkregex's differ
only by modules just internal names need (get, regex's parser
messages), which the root no longer evaluates.
arktype's keywords/ts entry is its own esbuild entry, so the modules
it reaches (closed under imports) form a chunk the main chunk imports
first; arktype's modules evaluate in a different order than the
parent's, which every check below passes. Dropping that entry restores
the parent's order exactly but gives keywords/ts.ts's path string's
json at runtime.
testBuild imports each module's .ts and .js deep path (161 modules,
plus the 4 internal ones), checks with the TypeScript checker over
out/*.d.ts that `<pkg>/internal` exports every module's names, and
that each root's runtime names are its index.d.ts names (it fails with
a module dropped from internal.d.ts or an export added to a root).
Runtime names: roots are 2.2.7's again (arktype 21, arkregex 1;
@ark/schema 210 -> 207, @ark/util 129 -> 128, the aliases);
`<pkg>/internal` has every non-alias name the old out/internal.js had.
pnpm perf compare against the parent's build (n = 200 processes per
root, load ~35):
- cold import("arktype"): 145.5 -> 155.7 ms (+7.0%, 95% CI -1.0 ..
+18.3%, p 0.078). Suggestive, and the size 05f35af measured for
the same split (1.080x): the root reads an index facade plus a chunk
in @ark/util, @ark/schema and arkregex, and arktype adds the
keywords/ts chunk.
- esbuild min/gzip: minimal app 203,641/62,825 -> 203,648/62,815
(+0.0%); all of arktype's root 210,670/64,816 -> 204,027/62,969
(-3.2%/-2.8%, the alias exports); @ark/schema alone -0.1%; arksets
unchanged.
Battery: identical (300 lines, md5 ca4828f6). Consumer probe (tsc,
NodeNext and Bundler): `arktype/internal`, deep paths and their
declaration emit compile; `import("arktype/internal/variants/
object.ts")` is still what a root-only consumer emits.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2.2.7 cloned the input through its descriptors before applying morphs,
so `type("string.trim[]")(Object.assign([" a "], { extra: 1 }))`
returned ["a"] with extra. 3.0 copies on write, and an array's copy was
data.slice(), which takes only the elements: extra was dropped whenever
an element, a tuple default or a declared prop changed, and kept when
nothing did (E-breaking row 30). David decided (10/06 06:49) to restore
2.2.7's behavior.
copyOf's array branch now gives the copy the array's enumerable named
props, the ones a spread gives an object's copy (row 28): its string
keys from Object.keys, which lists them after the indices, so a scan
back from the end finds them, skipped when the last key is the last
index; its enumerable symbols from getOwnPropertySymbols. defineValue
writes them, so an own "__proto__" stays an own prop. As for objects,
non-enumerable props are dropped (2.2.7 kept them) and untouched values
are the input's (row 24). The sequence's copy (JIT and jitless) and a
structure's array copy go through copyOf, and so does Traversal's
copyOnce, whose Object.assign(data.slice(), data) kept the props for a
morph on the failure path: copyOf is 2-14x cheaper than Object.assign
on arrays of 1-32 elements and doesn't [[Set]] "__proto__".
No JS API lists an array's named keys without its indices, so every
array copy now pays Object.keys and getOwnPropertySymbols. Microbench,
ns over a slice, load 8-25: Object.keys +130-240 at 1 element, +190-350
at 8, +650 at 32; getOwnPropertySymbols +135-195 flat. Reflect.ownKeys
and getOwnPropertyNames were each slower than the two together, for-in
slower than Object.keys, and 2.2.7's descriptor copy 8-50x slower.
Paired in-process (both builds in one process, 16 rounds, medians,
load 24-38), this commit against its parent:
trim array (8 elements) 356 -> 929 ns 2.61x p 1e-6
arr32 (32 objects) 2717 -> 3843 ns 1.42x p 3e-4
tuple default 88 -> 433 ns 4.92x p 1e-6
recursive morph 8671 -> 16118 ns 1.86x p 2e-5
moltar strip 1.09x (p 0.16), defaults 0.97 (p 0.88), nested defaults
0.99 (p 0.82), object morph 0.99 (p 0.88): they copy no array.
(p is Mann-Whitney; the arrays rows lost all 16 rounds, sign p 3e-5.)
The exit on the last index saves 4.5-11% against the scan alone (trim
0.92x, tuple default 0.90x, 15/16 rounds each, sign p 5e-4). The
symbols cost 7-43% even against string keys scanned without that exit
(tuple default 1.43x, trim 1.18x, 0/16, sign p 3e-5); they are kept
because 2.2.7 kept them and an object's copy does.
Test: "array with undeclared props" (fails before: extra and the symbol
are missing), JIT and jitless, through an element and a tuple default.
Battery: arrays/extra-named-prop keeps extra, sharing the input's
object; the other 298 lines are identical. The CHANGELOG's no-clone
entry loses its dropped-extra example.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
4542c85 added a `sequence (...)` segment, naming the declared props, to the structural morph's cache key of an array structure, because an array's copy kept only those props: two array structures with the same defaults then gave different outputs, and sharing one morph let union reduction drop props silently. The previous commit makes the copy keep the array's enumerable named props, as an object's copy does, so the segment no longer marks a difference the object key doesn't, and the key is 2.2.7's again: the defaults, plus the declared keys a deletion keeps. 2.2.7's reduction comes back: type("string[]") .and({ a: "number = 5" }) .or(type("string[]").and({ a: "number = 5", b: "string" })) is `{ a: number = 5 } & string[]` instead of throwing "indeterminate", and for Object.assign(["s"], { b: "x" }) it returns ["s"] with b and a = 5. 4542c85's `B.or(C)` (b and c props, equal defaults) no longer throws and keeps both b and c, as 2.2.7 does (JIT and jitless). Where outputs can still differ, a declared prop data holds as non-enumerable, they differ for objects too, which the key has never named; 2.2.7 kept such a prop in both. Test edit: "union of array props with shared defaults" asserted the throw; it asserts the reduction and that the reduced type keeps b (fails before: throws). The CHANGELOG's no-clone entry loses the indeterminate-union example. Battery identical to the previous commit. Emitted code is unchanged. Create (fresh process, 200 types each, 16 rounds) against 3.0: arrayDefaults 0.99x (p 0.73), defaults 1.06x (p 0.73), objectMorph 0.96x (p 0.47). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
96205db reached Callable's new `bind: null` branch only through type.test's "is an unbound function", which ran its assertions once per jit mode though Callable wraps a node the same way in both, and pinned facts the change did not touch: `T.length` is the internal arrow's arity, `T.toString()` is covered by the "toString()" test below it, and `Function.prototype.toString.call(T)` follows from the name. 2.2.7 tests Callable's name in util ("preserves original name"), so "can skip binding" sits beside it, and type.test keeps one assertion that a Type is unbound. 96205db's message also credits all three visible changes to David's 06:55 answer. The example he took then named only `T.name` and `Function.prototype.toString.call(T)`. The third, a stack frame through a Type keeping its file and line but losing the node class name (`MorphNode.<anonymous>` at the parent, `BaseNode.attach` in 2.2.7), was shown to him afterwards, and he kept it at 10:12. Its claim that the test pins all four, jit and jitless, no longer holds either; both belong to the history rewrite. Gates, rebased on a0f97eb: build, tsc, test (2084 passing; cyclicGenerated's three seed batches hit the 10s timeout at load 45 and pass at 10-13s each on rerun), testV8, testBuild, testIntegration, attest (63), prettier and eslint on the changed files. The parity battery against 96205db differs only in arrays/extra-named-prop (jit and jitless), whose output keeps its "extra" prop from 75f9d37; this commit changes no built code. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2e97197 deleted flattenIntoInternal and exportedNamesOf, and with them the bundle tests that read which module a value came from (`internal.apart.from`). Three pieces served only those: - bundle.test.ts's `traced` rewrote each `= {}` to `= { from: "<module>" }`, and `type Namespace` annotated the imported namespaces with that `from`. The remaining assertion, `attest(internal.x).is(root.x)`, compares identity, so neither is read; the namespaces are untyped dynamic imports, as the self-referencing class test's already are. - bundle.ts's `parse` wrapped ts.createSourceFile for exportedNamesOf and nameSelfReferencingClasses; with one caller left, the call is inlined there. The build output is byte-identical (diff -r of every package's out/ against the parent's build). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…path testBuild imported `<pkg>/internal/<module>.ts` and `.js` for all 161 modules, which 2e97197 counted as checking that every old deep import resolves. But `./internal/*.{ts,js}` map every match to one file (out/internal.js; arksets' out/index.js), whether the module exists or not: require.resolve("@ark/util/internal/missing.ts") gives util/out/internal.js. So the loop imported one cached file 322 times, and dropping, renaming or breaking a module passed it. What a deep import's runtime resolution can still get wrong is the wildcard itself, so each package now resolves `internal/index.ts` and `internal/index.js` and requires both to be its `internal` entry, as the internal/config.* check requires those to be `config`. Removing util's `./internal/*.js` fails it (ERR_PACKAGE_PATH_NOT_EXPORTED), and so does pointing its `./internal/*.ts` at out/index.js ("@ark/util/internal/ index.ts isn't @ark/util/internal"). Whether every module's names are reachable stays the checker's job over out/*.d.ts, unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
75f9d37's copyOf found an array's named keys by scanning Object.keys back from the end to the last key arrayIndexMatcher matched, skipping the scan when the last key was `${length - 1}`. Two cases broke it: - An empty array's `${length - 1}` is "-1", so a named "-1" key passed the last-key check and the copy dropped it. - arrayIndexMatcher is anchored, so "01" and "1.5" were never mistaken for indices, but it matches canonical integers at or above 2**32 - 1, which are named keys, not indices. The scan stopped at such a key and dropped it and every named key before it: Object.assign([" a "], { extra: 1, 4294967295: 0 }) through string.trim[] lost both. The exit now checks the key at index length - 1, which holds `${length - 1}` exactly when the array is dense: Object.keys lists indices first, ascending and all below length, so the named keys then start at length. A sparse array falls back to the scan, which stops at a key that matches and is below length, the only keys that can be an array's indices. Copies of dense arrays without such keys do the same work as before. Test: "array with undeclared props" adds the 4294967295 key to the trimmed array, an empty array's "-1" through a tuple default and a sparse array with a 4294967295 key; each fails before (JIT and jitless). Battery identical to 3.0 (300 lines). Paired in-process against 3.0 (51504cd), 20 rounds, medians, load 1.7-2.7: trim array 390 -> 402 ns 1.03x (p 0.81), tuple default 194 -> 200 1.03x (p 0.94), arr32 1710 -> 1777 1.04x (p 0.73, 5/20 wins, sign p 0.04), recursive morph 6232 -> 6219 1.00x (p 0.75); per-process minimums 1.00-1.03x (p >= 0.53). No measurable change. These numbers supersede 75f9d37's, which were measured before it was rebased and under load 24-38. Keeping an array's named props costs, on this commit against the same tree with 75f9d37's source reverted (paired, 20 rounds, load 2.5-2.7, every row lost all 20 rounds, sign p 2e-6): trim array (8 elements) 171 -> 399 ns 2.34x p 6e-8 tuple default 48 -> 195 ns 4.03x p 6e-8 arr32 (32 objects) 1200 -> 1567 ns 1.31x p 1e-5 recursive morph 3741 -> 6643 ns 1.78x p 6e-8 (p is Mann-Whitney; per-process minimums: 2.36x, 4.15x, 1.35x, 1.77x.) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
David revisited the 06:50 decision behind e3e996e at 09:31: ignoring undeclared symbol keys under "reject" is imprecise from a type perspective, so 3.0 rejects them by default, as 2.2.7 does, and ignoring them becomes an opt-in option (next commit). This takes back e3e996e and the three commits that refined it: - structure.ts: the compiled Allows and Apply read Object.getOwnPropertySymbols again whenever the structure rejects undeclared keys, and switch on its symbol-keyed props there. compileExhaustiveEntry takes the props to switch on again, as at eb99d22, since both key loops call it; 32a3b98 had restored 2.2.7's compileExhaustiveEntry(js) only because the symbol loop had stopped calling it. The interpreted traversal reads every own key again and rejects an undeclared symbol key. - arksets: a reject side's allowed keys are its keyof() again (allowedKeysOf is gone), so `.and({ [s]: "number" })` throws, a `[symbol]` index intersected with a reject type is dropped and `.extends({ "[symbol]": "number" })` is true, as on 2.2.7. - The objects and configuration docs and the UndeclaredKeyBehavior JSDoc say "keys" instead of "non-symbolic keys", and the build regenerated apiData.ts and the schema declaration bundle, both now identical to eb99d22's. - The CHANGELOG's reject entry is gone. Its last sentence, that "+": "delete" removes undeclared symbol keys (which 2.2.7 kept), describes an earlier change that stays, so it becomes its own entry. The emitted code and .json of 17 types (MoltarStrict; reject plain, empty, with string, pattern, symbol and string | symbol indexes, a symbol prop, an array, a morph; delete with a pattern index and with a symbol prop; four without undeclared handling) are byte-identical to eb99d22's, so strict validation costs what it did there: e3e996e measured the change this undoes at x0.323 for strict allows, x0.335 for strict parse and x0.881 for a 40-key strict object (n = 20, sign p 1.9e-6, 1.9e-6 and 4.0e-4). Tests: "reject symbol key" pins the rejection in jit and jitless; it fails on the parent. e3e996e's "reject ignores symbol keys" and "intersects a symbol index with reject" asserted the default this undoes and are removed. No 2.2.7 test changed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
David, 09:31, on making "+": "reject" ignore undeclared symbol keys:
"at best maybe we can make it an option so we can still benchmaxx but
by default that option should be off". With
configure({ rejectAllowsSymbolKeys: true }), "reject" skips the
Object.getOwnPropertySymbols read and allows any undeclared symbol
key, as e3e996e did by default; without it nothing changes.
The name follows numberAllowsNaN and dateAllowsInvalid: its subject is
the "reject" behavior it relaxes, as theirs are the number and Date
they relax, and "SymbolKeys" says it allows keys, not symbol values.
Like them and onUndeclaredKey it is global only, and applyConfig writes
it into the inner of each structure that rejects undeclared keys, so
.json and the node's hash carry it. It isn't a scope option: nodes
derived from a node (an intersection in ctx.$, pick and merge in
this.$) go back through applyConfig in whichever scope derives them,
so a reject node from a scope without the setting would gain it from
one with it. That is the cache problem #1255 made the type-altering
options global for.
With the key set, the compiled Allows and Apply read symbol keys only
when an index may match one, and check that index alone, and the
interpreted traversal reads them only when there is an index and
doesn't reject them. compileExhaustiveEntry takes the key domain it
compiles for in place of the props it switches on, so the symbol loop
compiles the reject check only when symbols are rejected. In arksets a
reject side that allows symbol keys has keyof() | symbol as its
allowed keys (allowedKeysOf), and a reject intersection allows them
only if every reject side does. A side's explicit true or false
carries over; a reject side without the key leaves it unset, so
applyConfig derives it as it does for any new reject node. merge takes
r's setting along with its undeclared behavior, onUndeclaredKey clears
it so applyConfig derives it again, and it flattens into an
intersection schema as undeclared does, so .json still parses.
Emitted code: with the default config, the precompilation and .json of
17 types (MoltarStrict and MoltarStrip; reject plain, empty, with
string, pattern, symbol and string | symbol indexes, a symbol prop, an
array, a morph; delete with a pattern index and with a symbol prop;
four without undeclared handling) are byte-identical to eb99d22's,
e3e996e's parent, so the default has no extra branch on the hot path.
With the option, the precompilation is byte-identical to e3e996e's
and .json gains rejectAllowsSymbolKeys: true. Every structure node has
one more own property, undefined unless the option is set, as each
inner key is (testV8: still one map per kind).
Measured against eb99d22 with both builds in each process, 20
processes per row, load order alternating, load 2.6-6.1:
- default: strict allows x0.997 (variant faster 10/20, sign p 1.0),
strict parse x1.007 (9/20, p 0.82), 40-key strict allows x0.998
(12/20, p 0.50), strict invalid allows x1.014 (9/20, p 0.82), plain
allows x0.998 (11/20, p 0.82). No difference, as the code is the same.
- option: strict allows x0.384 (159 -> 62 ns), strict parse x0.394,
40-key strict allows x0.880, strict invalid allows x0.900, each
20/20, sign p 1.9e-6; plain allows x0.993 (11/20, p 0.82).
The saving is a fixed cost per object, so it shrinks as objects grow.
e3e996e measured x0.323 for strict allows at load 35-48; at this
load it is x0.38, so the CHANGELOG says about 2.5x, not 3x. Two
corrections to e3e996e's body: its "~300 -> ~100 ns" came from a
separate n = 3 run at load 60 (262-342 -> 89-120 ns), not the n = 20
run it sits under, and a reject type with a symbol-matching index
keeps its symbol loop and loses only the reject check, rather than
losing the loop as that body says of every reject type.
A differential over 12 reject and delete definitions x 11 keyed inputs
x jit and jitless, plus 17 algebra cases (and, extends, merge,
onUndeclaredKey, .out, .json), matches eb99d22 by default and
e3e996e with the option, except that a delete type's .out, whose
structure rejects undeclared keys, carries rejectAllowsSymbolKeys:
true in its .json. The parity battery is identical to the parent's
(300/300) with and without the option; it has no undeclared symbol
key. The type-instantiation benches are unchanged.
Tests: "rejectAllowsSymbolKeys" (config.test, beside numberAllowsNaN
and dateAllowsInvalid) covers a plain and a pattern-index reject type
in jit and jitless, an undeclared string key still rejected, and the
[symbol] index an intersection keeps. It fails if jitless's typeof
check, the compiled symbol loop or allowedKeysOf ignores the option.
No other test changed.
The CHANGELOG adds the option with its measured effect, and the
configuration docs show it under onUndeclaredKey.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A structure schema accepted rejectAllowsSymbolKeys alongside "delete" or no undeclared behavior. There it changes nothing, but it still appears in .json and in the node's hash, so the same type could parse to two nodes. normalize now throws for it, as domain and proto throw for numberAllowsNaN outside "number" and dateAllowsInvalid outside Date: rejectAllowsSymbolKeys may only be specified with undeclared "reject" (was delete) It checks for a defined value rather than for the key, since onUndeclaredKey clears the setting by writing undefined into the inner it reparses. The check runs before the set engine's early return, so it holds with or without arksets. Every structure with the key now rejects undeclared keys, so arksets' allowedKeysOf tests the key alone. The default and the option's validation and set algebra are unchanged: a probe of 12 reject and delete definitions over 11 keyed inputs in jit and jitless, plus 17 algebra cases (and, extends, merge, onUndeclaredKey, .out, .json), prints the same as before this commit, with and without the option. Tests: "rejectAllowsSymbolKeys" asserts the throw for a "delete" schema. The build regenerated the schema declaration bundle for the new export. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With rejectAllowsSymbolKeys, the interpreted traversal reads symbol keys only for a structure with an index, and must not reject one. It tested declaresKey first, a props lookup plus each index signature's allows, only to discard the result for any symbol key. It now checks the key's type first, as the compiled code decides at compile time which symbol keys it rejects. By default the condition is reached for the same keys and still calls declaresKey for each, now after a typeof test that passes for string keys and a flag read for symbol keys. Measured in jitless scopes against the parent's build, both builds in each process, 16 processes per row, load order alternating, load 1.6-3.5: - default: MoltarStrict allows x0.992 (variant faster 10/16, sign p 0.45), parse x1.002 (8/16, p 1.0), a pattern-index reject type failing on an undeclared symbol key x1.005 (7/16, p 0.80) - option: the same pattern-index type allowing four undeclared symbol keys x0.855 allows and x0.848 parse (16/16, p 3.1e-5); MoltarStrict allows x0.994 (9/16, p 0.80), as it has no index and reads no symbol keys Compiled code is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With the option, a reject type allows every undeclared symbol key, so
reject types that differ only by a symbol key are no longer disjoint.
A union of them with a morph then throws at parse time, where the
default parses it:
type({ "+": "reject", a: "string.trim" }).or({
"+": "reject",
a: "string",
[s]: "1"
})
// ParseError: An unordered union of a type including a morph and
// a type with overlapping input is indeterminate
The throw is right under the option's semantics, since
{ a: " x ", [s]: 1 } matches both branches, but opting in for speed
can turn a working definition into a ParseError, so the CHANGELOG
entry shows it and the configuration docs say it in a sentence.
Checked against the built package with and without the option.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
allowsUntracked saves the enclosing root's visit count and tracked continuation, resets both for its own traversal and restores them after it; allowsAcyclic does the same for aliasVisits.tracked. Both restored only on return. A top-level call resets on entry, so what a throw left behind reached only a root still running: one whose predicate calls a cyclic type and catches what it throws. Then, in jit and jitless: - past the depth bound, the enclosing root continued the nested root's Traversal, whose resolutions were still entered. Data reaching one was assumed valid, so allows returned true for a chain whose last value is invalid. dac8f8a shares one continuation per root on the invariant that each hit settles what it assumed before the next reads it, and the throw broke that. - a nested root apply that threw before the bound left `tracked` false, so a transforming root that had passed the bound took its data as acyclic and overflowed the stack on a cyclic object. 2.2.7 had neither, since each root call had its own ctx. The new traverse.test case fails on 3.0 at its first assertion (true), and at its second (RangeError) with only allowsUntracked fixed. allowsUntracked restores in a catch that rethrows, so its return path is unchanged: a finally there slowed the cyclic rows whose allows takes tens of ns. Head to head, paired, n=30, load 3.6-7.1, catch against finally: list apply 0.90x (p 0.010), list allows 0.98x (p 0.060), list invalid allows 0.99x (p 0.075), mutual 0.99x (p 0.18). allowsAcyclic runs only on transforming roots, where a finally measured no cost, so it uses one. The emitted code is unchanged: for each row below, its scope's precompilation, root apply and allows are byte-identical to 3.0's. Against 3.0 (bd8f17d), paired in one process per sample (both builds loaded, loops alternated ABBA, which build loads first alternating by process), median of 30 rounds; p is Wilcoxon signed-rank on the log ratios: row n load 3.0 -> this ratio p list allows (8 deep) 30 3.1-6.2 61.0 -> 59.4 0.98 0.15 list apply 30 3.1-6.2 61.9 -> 63.2 1.02 0.85 list invalid allows 30 3.1-6.1 55.0 -> 57.1 0.99 0.72 mutual allows 30 3.2-5.8 50.2 -> 50.1 0.99 0.93 morph apply (acyclic path) 30 3.2-5.8 731 -> 729 1.00 0.85 list invalid apply 20 2.9-7.4 5447 -> 5427 1.00 0.63 deep allows (100, bound) 20 2.8-7.4 8138 -> 8358 1.00 0.97 tree allows (depth 3) 20 2.8-7.5 502 -> 508 1.00 0.91 tree apply 20 3.0-7.4 475 -> 484 0.95 0.60 flat allows (acyclic) 20 2.9-7.4 3.9 -> 3.9 1.01 0.68 (ns; ratio is the median per-process ratio.) An A/A run, 3.0 against a copy of itself on the first five rows (n=20, load 3.3-8.0), gave 0.99-1.02x, p >= 0.30. No measurable change. Battery identical to 3.0 (300 lines, jit and jitless). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`static readonly [Symbol.species] = Array` is a define on native class
fields, but a toolchain that transpiles class fields with assignment
semantics emits `ArkErrors[Symbol.species] = Array`. That assignment
walks the prototype chain to Array's getter-only `Symbol.species` and
throws a TypeError in strict code as soon as the module loads:
TypeError: Cannot set property Symbol(Symbol.species) of function
Array() { [native code] } which has only a getter
`pnpm buildDocs` hit it: Next's SWC compiled the workspace source to
`static { this[r] = Array }`, and prerendering /playground failed, so
prChecks failed on 3.0. Loose class-property transforms (Babel's loose
mode, as in React Native's preset) would fail the same way for users
who transpile dependencies. 2.2.7's static getter did not have the
problem, since an accessor is always defined.
The species is now set with `Object.defineProperty` after the class,
the pattern TraversalError already uses for `arkErrors`, and the field
stays as a `declare` so `.d.ts` output is unchanged (diffed:
`static readonly [Symbol.species]: ArrayConstructor`). It is still a
data property rather than a symbol-keyed static accessor, which keeps
6b3f672's instanceof fast path; that investigation (C1-v8) found
`Object.defineProperty` and a static field equally fast. A probe here
at load 17-30 could not resolve the effect for any form, the 2.2.7
getter included, so no new number is claimed.
Gates on this commit: build, tsc, test (2088 passing), testV8,
testBuild, testIntegration, attest (63 passing), buildDocs (passes;
failed before), prettier and eslint on the file.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`pnpm buildDocs` writes llms.txt from the docs content, and the committed copy predated 6b71f33 (clone config removed) and 3937f4d / 3f56190 (rejectAllowsSymbolKeys). It still documented `clone` and lacked `rejectAllowsSymbolKeys`. This is the build's output verbatim; two docs builds (shared worktree and lane) produced identical diffs. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The comment said a finally slows every cyclic allows, a line above allowsAcyclic's finally on a cyclic allows. A reader would "fix" one or the other. Measured, a finally cost only the short cyclic rows. With a finally in allowsUntracked (and in allowsAcyclic), against 3.0, paired, n=50, load 1.1-3.5: list apply 1.043x (p 0.003), mutual 1.026x (p 0.006), list allows 1.023x (p 0.016), morph apply 1.011x (p 0.012), list invalid allows 1.010x (p 0.002); list invalid apply, deep, tree and flat n.s. (n=20, p >= 0.26). That run is what justifies the catch. allowsAcyclic runs only on transforming roots, whose apply takes hundreds of ns, so it keeps its finally: with it and the catch, morph apply measured 1.00x against 3.0 (n=30, load 3.2-5.8, p 0.85), within the A/A spread. The message of the fix this follows led with a weaker run instead: catch against finally (n=30, load 3.6-7.1), whose list apply 0.90x (p 0.010) the two runs against 3.0 put near 0.98x (1.043x for a finally, 1.02x for the catch), so that row is noise; its other rows were n.s. (p 0.06-0.18), morph apply 1.006x (p 0.13) among them. Its table, the catch against 3.0, was taken at load 2.8-7.5, so it rules out only changes outside the A/A spread (0.99-1.02x), not the 1-4% detected above at load under 3.5. That table's base, bd8f17d, has the same traversal.ts as the fix's parent. Comment only; built behavior is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s name
mergeTransformed merges a later Transform step's output r, computed on
a key's input, into the previous step's output l. A key of the input
that r lacks was deleted by the later step, so the merge deletes it
from l's copy. The check was `k in r`, which walks r's prototype chain:
a deleted key named like an Object.prototype member (toString, valueOf,
constructor, hasOwnProperty, ...) read as present, so l's value
survived. The check is now an own-key check,
Object.prototype.hasOwnProperty.call, the form structure.ts uses for
the same question.
Reproduction, jit and jitless:
type({
"p?": ["object", ["string", "=", "d"]],
"[string]": { "+": "delete", "[/^\\d+$/]": "object" }
}).assert({ p: Object.assign([{}], { toString: 1 }) })
p's tuple default appends "d", which the index value rejects, so the
index value transforms p's input, deleting its undeclared toString, and
the two outputs merge. p kept toString: own keys ["0", "1",
"toString"]; now ["0", "1"], as for a key named "z".
The bug came in with dcde765 on 3.0; 2.2.7 has no mergeTransformed,
so no released behavior changes. Only keys with two or more
transforming steps reach this loop, which no bench row has.
Test: "index transform deleting an inherited key name" in
transform.test.ts fails before (own keys include "toString") and
passes after.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The 3.0.0 entry for removing `clone` said transforming never writes to the input. That holds for plain objects, arrays and the builtins copyOf can copy (Date, Error, Map, Set, RegExp, URL, Headers, typed arrays, ...), but a builtin whose state can't be read or rebuilt (a function, Promise or WeakMap) or isn't copied (FormData, Blob) is transformed in place and returned, by design since 1a9a6fc and as 2.2.7 did for every input. A Function, Promise or WeakMap with a `string.trim` prop comes back as the input itself with the prop trimmed on the caller's object, and an object holding one is returned as is. The sentence now names that exception. No other doc repeats the claim. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
copyOf copied an array's elements with data.slice(), which builds its
result through ArraySpeciesCreate: it reads data.constructor and, if
that is an object, its [Symbol.species]. An array's named keys are
user data, so an own `constructor` key decided the copy:
type("string.trim[]")(Object.assign([" a "], { constructor: 1 }))
// TypeError: object.constructor[Symbol.species] is not a constructor
and an own `constructor: Sub` (Sub extends Array) gave a plain array's
copy Sub's prototype. 2.2.7 does the same (its deepClone also sliced),
so both are released behavior, but the first is a crash on valid input.
slice is kept where its constructor is Array, so the common copy is
unchanged. Any other array, an own constructor key or a subclass, is
copied by concat on a new array, which reads no constructor of data
and keeps holes as slice does, and is given data's prototype, as
copyOf does for any other object. A subclass's copy is therefore still
an instance of it, but its constructor is no longer called to make it,
and its Symbol.species no longer chooses the copy's class (no test
relied on either).
A species-free copy for every array was measured first and rejected:
concat plus a prototype check, paired in-process against 3.0
(0835919) from source, 20 rounds, medians:
trim array (8 elements) 388 -> 428 ns 1.10x 1/20 wins, sign p 4e-5
tuple default 197 -> 228 ns 1.16x 0/20, sign p 2e-6
arr32 (32 objects) 1585 -> 1657 ns 1.05x 8/20, p 0.5
recursive morph 6340 -> 6737 ns 1.06x 1/20, sign p 4e-5
and the prototype check alone (slice kept) cost 1.07x and 1.08x on the
first two (0/20 and 1/20 wins). This commit, same setup:
trim array (8 elements) 408 -> 415 ns 1.02x 11/20 wins, p 0.91
tuple default 201 -> 202 ns 1.01x 10/20, p 1.0
arr32 (32 objects) 1727 -> 1701 ns 0.98x 10/20, p 0.94
recursive morph 6712 -> 6622 ns 0.99x 10/20, p 0.77
(p is Mann-Whitney, sign p the binomial on per-round wins), so no
measurable change. The check names `constructor` because it is slice's
own precondition; no general species-free copy matched slice's speed.
Tests: "array with undeclared props" (jit and jitless) adds an array
with an own constructor key, which threw before; "preserves prototypes"
adds an Array subclass, which passed before and pins that its copy
keeps the subclass.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
aabdb2f copied an array whose constructor isn't Array with [].concat(data), which honors data's Symbol.isConcatSpreadable. For a subclass that sets it to false, concat yields [data], so the copy took one element and the transformed index 0 overwrote it: class NotSpread extends Array { get [Symbol.isConcatSpreadable]() { return false } } type("string.trim[]")(NotSpread.from([" a ", "b"])) // ["a"], length 1 2.2.7 and 3.0 before aabdb2f returned ["a", "b"]. A plain array with own `constructor` and `[Symbol.isConcatSpreadable]: false` keys threw before aabdb2f and lost "b" after it. That branch now builds the copy without reading any key of data besides its indices: an empty array given data's prototype and length, with data's index keys (the keys[0..i) copyOf already finds, so holes stay holes) defined on it. The named keys and symbols are copied as before. The Array-constructor path still slices. Tests: "preserves prototypes" makes its List subclass non-spreadable with two elements, and "array with undeclared props" (jit and jitless) adds a non-spreadable key to its constructor-key array. Both lost "b" at aabdb2f. Perf, paired in-process from source against aabdb2f, 20 rounds: medians (MWU p, per-round wins of this commit): trim array (8 elements) 626 -> 623 ns 0.996x 13/20, p 0.81 tuple default 290 -> 290 ns 0.997x 12/20, p 0.83 arr32 (32 objects) 2660 -> 2573 ns 0.97x 10/20, p 0.89 recursive morph 9955 -> 9712 ns 0.98x 9/20, p 0.89 so no measurable change (the machine was loaded, hence the higher absolute times than aabdb2f's run). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
5f1b47e described the builtins a transform writes in place as those "whose state can't be copied". Whether one is written in place depends only on whether copyOf has a copier for its kind (typed arrays, ArrayBuffer, Date, Error, Headers, Map, RegExp, Set, URL), not on whether its state could be copied: FormData, Blob and boxed primitives (String, Number, Boolean) all can be, yet each is trimmed in place, as are WeakSet, File, Request and Response. The sentence now says "a builtin arktype doesn't copy" and adds boxed primitives to the examples. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Three bugs in ArkErrors.add and merge that 2.2.7 shares:
- An intersection formed away from ctx.path lost its path. Two errors
added at the same relative path, e.g. by ctx.reject({ relativePath })
twice in a root narrow, were stored under byPath["a"] but merged into
an intersection built from the traversal's own path, so its
propString was "" and its message read ({"a":1}) must be... with no
path. The intersection now takes its path and data from the error it
replaces: a ({"a":1}) must be...
- An error already merged at its path was merged again. A morph that
calls ctx.error at a path and then returns another ctx.error there
added the returned error a second time, giving ◦ p ◦ q ◦ q and a
count of 3. An error that is already a member of the intersection at
its path is now ignored and not counted: ◦ p ◦ q, count 2.
- A morph that returned ctx.errors merged the ArkErrors into itself.
At the root both versions listed each error twice. At a key, 2.2.7's
merge pushed each copy, at a longer path, onto the array it was
iterating, adding errors until it ran out of memory; 3.0 listed every
error again under the key, a sibling key's errors included (count 3
for one error). merge(this) is now a no-op, since every error in it
has already been added.
These come first because the next commit derives byPath from the
errors themselves, which needs each error's propString to equal the
key it is stored under.
Parity battery (27 invalid cases, 3.0 ae11311 against this commit):
only the cases above differ. Measured together with the next commit.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ng Array
ArkErrors extended Array, and 3.0 had already worked around three
costs of that: push on a subclass, Symbol.species (defined, not
assigned, to keep instanceof fast and to survive transpilers that
assign static fields), and join's generic ToPrimitive. David, 10-06:
"for 3.0 ArkErrors should not subclass array it's too much of a mess.
find a better abstraction that essentially maintains a very similar
API with the same levels of granularity".
ArkErrors is now a plain class. Its errors live in issues, an own,
plain Array with one ArkError per path in the order each path first
failed, which is the array Standard Schema consumers read. length and
[Symbol.iterator] forward to it, so for...of, spread, Array.from and
destructuring keep working, and every other member keeps its 2.2.7
granularity: byPath per path, flatByPath and flatProblemsByPath per
error, byAncestorPath per ancestor path, count per add.
byPath and byAncestorPath become lazy getters over issues:
- byPath is built on its first read, or by the first add once 8 paths
exist, and kept current by add from then on. Below 8 paths with no
index, add finds an existing path by scanning issues backwards
comparing cached propStrings, and the first add reads none at all.
- byAncestorPath is built on its first read and appended for new
paths; a merge into an existing path drops it, so the next read
rebuilds it in issue order. Its values are flat errors in issue
order, then intersection member order (2.2.7: add order), an
intersection merged in from another ArkErrors lists its members, and
an error a never error replaced is gone.
Invariants, checked on the 27-case parity battery: issues has one
entry per propString, each entry's propString is its byPath key (the
previous commit's B1 fix), both indexes equal a fresh build from issues
whenever they are read, add is the only writer (merge and transform go
through it), count >= length, and every ArkErrors has one V8 map: empty,
1, 3 and 20 errors and after reading the indexes (testV8; on 3.0 the
empty one differed by elements kind). Valid data still builds no
ArkErrors (0 allocations over 14 morph shapes x call, ~standard and
assert, jit and jitless), and the JIT code is untouched: it reads only
currentErrorCount and compares ctx._errors by identity.
Removed: the Array base, species and its defineProperty, the mutable
alias, index keys, the inherited Array methods and the from/of/isArray
statics. out.map(...) and out[0] become out.issues.map(...) and
out.issues[0], which also work on 2.x, where issues returned the
ArkErrors itself. Array.isArray(out) is false, arr.concat(out) nests
it, and a Standard Schema failure's issues is a plain array, so
r.issues.summary is undefined. The CHANGELOG lists the migration;
AE-design.md section 2 has every observable difference.
Rejected:
- Forwarding every non-mutating Array method to issues (the
runner-up): no breaks for the 8 sampled code files in 7 repos that
call array methods on a result, but about 35 more members, a callback
third argument that is issues rather than the result, and a type that
still reads as array-ish. David may still choose it.
- Dropping length and iteration too: breaks the documented for...of and
11 sampled iteration sites, and out.length becomes a silent undefined.
- Own index keys, so lodash, Array.prototype.x.call and index loops
stay right: +264 ns (x1.88, 0/20, p 1.9e-6) per 1-error result.
- Symbol.isConcatSpreadable, so concat spreads: makes every concat in
the process x5.72 slower (0/20, p 1.9e-6).
- An eager byPath without the scan ("dict"), same harness: form x0.838
in the scan's favor (17/20, p .003), strict x0.927 (15/20, p .041),
container 2-4 paths x0.78-0.85 (p <= .005); ties at 1, 16, 100 paths.
Test edits, each reading the result through issues because it is no
longer an Array, none loosened:
- schema errors.test "Array methods allocate plain Array
(Symbol.species)" becomes "issues is a plain array": map on issues
still allocates a plain Array, Array.isArray is false for the result
and true for issues, spreading the result equals issues, and length
equals issues.length.
- bounds.test -0 rule, cyclic.test deep union, cyclicGenerated pathsOf,
repo/testBuild bundle class names: out[0] / out.map -> out.issues.
- standardSchema.test: the failure is type.errors and its issues is an
array (was: issues is type.errors).
- attest assertions.test "doesn't boom on ArkErrors vs plain object":
printable shows ArkErrors where it showed [ArkError].
New tests: the loud breaks at the type level (out[0], out.map,
out.push, out.length = 0, out.issues.push, assigning to
readonly ArkError[], concat), byAncestorPath read between adds equals
read after, a never error replacing the errors at its path, and errors
at 10 paths with repeats at the 4th and 10th.
Measured on the built packages against 3.0 in steps/arkerrors-result.md.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Superseded by #1685.