Simplify the completed SchemaBinary implementation - #7371
Merged
tim-smart merged 2 commits intoAug 20, 2026
Conversation
Whole-implementation cleanup after the codec, arena output, integral varints, parser hot paths, and fingerprint mode were built in separate stages. No public API, wire format, or error-surface change: default and fingerprint golden bytes, decoded values, issue text and paths are byte-identical before and after. - One byte-sequence builder feeds both FNV hashes; `sentinelSetHash` no longer carries its own copy of the 32-bit fold and the u32le encoder. - `Exit`, `Cause`, and `CauseReason` share one compiled `ReasonLayout` instead of rebuilding a cause layout per encoded and decoded value. - `decodeSized`, `decodeInline`, and `decodeExtraPair` mirror `encodeSized`, `isInlineSlot`, and the extra-key writer, removing three copies of each rule from the struct and array decoders. - One `encodeUnion` picks the member for both modes and writes only the selector that differs. The decode halves stay split: folding them would put fingerprint mode's fail-closed rule behind a flag. - `addMissingKey` / `throwMissingKeys` give both struct decoders one missing-required-field surface. - Documents arena ownership, the error surface, the parser cache bound, and the index-signature key-filter seam, and drops the public doc link to the non-exported `layoutFingerprint`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Remove probe-after.txt, which a stray `git add -A` swept into the previous commit. It was pnpm/nix error output from a throwaway harness and never belonged in the tree. Add `decodeSlot`, a single-pass version of `isInlineSlot` plus `decodeInline`, and use it from `decodeArray` only. Struct fields carry an `inline` flag computed at compile time, so `decodeStructPositional` classifies nothing per value; a tuple slot has no such flag, so asking `isInlineSlot` and then letting `decodeInline` re-derive the same answer classified every element twice. `decodeInline` and `decodeSized` stay for the struct path. Replace `addMissingKey` with `missingKeyIssue`. Threading the array through a return value to do a push hid the accumulator; the duplication worth sharing was only the Pointer/MissingKey construction. No wire, value, or error-surface change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 20, 2026
tim-smart
added a commit
that referenced
this pull request
Aug 21, 2026
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
tim-smart
added a commit
that referenced
this pull request
Aug 21, 2026
Co-authored-by: Claude Opus 5 <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.
Final simplification pass over
SchemaBinary, reviewing the combined implementation as a whole: the original codec, arena output, integral varints, the parser hot-path work, and both wire modes.Closes EFF-783
Stacked on
agent/codex-engineer/58cacc24at0c8163ece5, which is the head containing merged #7370. This branch and PR stay open for the bundle-size pass in EFF-784.No behaviour changed, and that is checked rather than asserted
A differential harness encoded 34 schema/value pairs in both wire modes (every leaf kind, optional and full presence bitmaps, index signatures, tuples with rest, tagged and untagged unions,
Option/Result/Exit/Cause/CauseReason, recursive trees), 12 error cases (missing field, wrong type, truncated frame, leftover bytes, flipped envelope, flipped body byte, in both modes), and byte-by-byte streaming feeds. It ran against the base source and against this branch: 86 lines of hex payloads, decoded values, issue text and issue paths, identical.The benchmark's payload-size columns are also unchanged, which is the same claim from a second direction.
What was removed
One byte-sequence builder for both hashes.
sentinelSetHashcarried its own copy of the 32-bit FNV fold and its own u32le encoder, and allocated a one-elementUint8Arrayper mixed byte. The fingerprint walk already hadpushU32/pushUvarint/pushU64. Those move up next tofnv32/fnv64, andsentinelSetHashbuilds one array and hashes it once. Two hashes, one set of encoders.Exit,Cause, andCauseReasonshare one compiled node. Theexitlayout carried looseerroranddefectchildren, so both the encoder and the decoder built{ _: "cause", error, defect }fresh on every failedExit. They now share aReasonLayoutcompiled once. The fingerprint still hashes the same three children in the same order, so the hash does not move.Three decode rules that existed in two or three copies.
decodeSized,decodeInline,decodeSlot, anddecodeExtraPairmirrorencodeSized,isInlineSlot, and the extra-key writer. Before this,decodeStructPositionalreimplementeddecodeArray's inline-slot rule with the null/undefined branch folded differently, and the extra-key loop existed twice, differing only in whether a count orr.pos < r.endbounded it.compileArrayalso spelled outisInlineSlot's body inline rather than calling it. The encode side has hadisInlineSlotsince the arena work; the decode side now says the same thing once.The struct and tuple paths get different helpers on purpose. A struct field carries
inline, computed once at layout-compile time, sodecodeStructPositionalclassifies nothing per value andfield.inline ? decodeInline : decodeSizedis free. A tuple slot has no such flag. Routing it throughisInlineSlotand thendecodeInlinewould runpackedSizeandisSelfDelimitingtwice per element, sodecodeSlotmakes that decision once. Same shared rule, one classification.One union encoder.
encodeUnionandencodeUnionPositionalran the same member scan and differed only in the selector written for the member that matched. They are one function whose twoctx.positionalbranches sit on the matched arm, so the branch count per encode is what it was.One missing-required-field surface.
missingKeyIssue/throwMissingKeysreplace the copy in each struct decoder. Only thePointer/MissingKeyconstruction is shared; the accumulator stays at the call site as(issues ??= []).push(...), where it is read.An unreachable guard.
IndexSignatureCachethrew on a non-positive capacity; the only bounded caller passes a module constant.What was deliberately left alone
decodeArray's uniform loop stays specialised.Schema.Array(S)settles the slot layout and the length rule once outside the loop. Routing it throughdecodeInline/decodeSizedwould movepackedSizeback inside the per-element path.decodeStruct's duplicate-id bookkeeping stays. The 32-bit mask with set fallbacks, and the separateseenandpresentmasks, are each load-bearing: a known union member may decode to absent, so presence cannot reuse the duplicate mask.astKindandcompileDeclarationboth switch on the representation id. They return different things (a wire kind, a layout) and merging them would not remove the agreement requirement.Documentation
The module docstring now states arena ownership and the error surface: which failures are
InvalidValue, which are aMissingKeyunder aPointer, and that schema-author bugs throw anErrorat layout-compile time rather than surfacing as an issue.parserdocuments why its options are fixed at construction, the 256-entry cache bound and its one-replacement-per-frame rule, andmaxFrameSize.The index-signature key-filter seam raised in the #7370 review is now written down at
matchIndexSignature: a key predicate is a check, checks never reach the wire or the fingerprint, so a reader cannot distinguish "the writer sent a key I filter out" from "the writer used a different schema", and such a key is dropped where a mismatched field id fails the frame.Optionsno longer links{@link layoutFingerprint}, which is not exported.Benchmarks
nix develop -c pnpm --dir packages/effect exec node benchmark/schema/SchemaBinary.ts, Node 26.7.0 on Linux x64.Sequential before/after runs showed a consistent negative skew that turned out to be machine drift, so the numbers below are seven interleaved before/after pairs (stash, run, restore, run), which cancels it. Medians per row, 104 SchemaBinary rows across one-shot encode/decode and all three streaming feeds:
Nothing outside ±2.1%, which is this benchmark's run-to-run spread.
Two real regressions were found and fixed rather than absorbed. The first interleaved pass showed
small record / parser / single frameat -2.8% with non-overlapping distributions across three pairs (802k/809k/803k against 780k/780k/784k). The cause was acheckMissingKeys(layout, issues)call added on every struct decode, where the original code had an inlineissues !== undefinedcheck. The one-shot path hid it because the Schema transformation wrapper dilutes it; the parser path does not. Guarding at the call site so only a failing decode calls out restored it: that row is now +0.3% pooled, and the case that had regressed reads +1.9% in the pair immediately after the fix.The second came out of review: the tuple slot double-classification above. It hides in the aggregate because the repo benchmark's only tuple case is
Array(Tuple([Number, Number, Boolean])), where the actual field decoding dwarfs the classification. Isolating it needs both a slot shape that is nothing but classification and enough elements to swamp the per-call cost of the Schema wrapper. At 1024 elements,Array(Tuple([Boolean, Boolean]))decode, six interleaved rounds, ns per element:8661864dcatuple[bool,bool]tuple[num,num,bool]tuple[null,bool]tuple[str,num]decodeSlotputs the two-boolean case back on base. The other three shapes never left it, which is the expected result: they do enough real work per element that one redundantpackedSizedoes not register.The aggregate benchmark was re-run interleaved against base after both fixes, four pairs, and is unchanged: median +0.00%, mean -0.04%, range -2.3% to +3.2%.
The benchmark markdown is unchanged. Its size columns are exactly right, and its rates still describe this tree within noise; re-running fresh machine-local numbers over it would be churn.
Verification
nix develop -c pnpm vitest run --project effect test/unstable/encoding: 117 passednix develop -c pnpm vitest run --project effect: 8710 passed, 47 skippednix develop -c pnpm lintandnix develop -c pnpm check: cleanOne test added: both
Exitbranches round-trip in fingerprint mode, where the failure branch has no length prefix to resynchronise on, and theExitandCausefingerprints stay distinct despite the shared node.For EFF-784
isCyclicpullsChunk,HashMap,HashSet, andRedactedinto the module purely for cycle detection. That is a bundle-size question rather than a simplification one, so it is left for the next stage rather than guessed at here.🤖 Generated with Claude Code