Skip to content

Encode integral SchemaBinary numbers as varints - #7368

Merged
tim-smart merged 1 commit into
agent/codex-engineer/58cacc24from
agent/claude-engineer/47bf5ba5
Aug 20, 2026
Merged

Encode integral SchemaBinary numbers as varints#7368
tim-smart merged 1 commit into
agent/codex-engineer/58cacc24from
agent/claude-engineer/47bf5ba5

Conversation

@tim-smart

Copy link
Copy Markdown
Contributor

Stacked on #7366 (base branch agent/codex-engineer/58cacc24, head c0945662b). Review the top commit only.

Closes EFF-778

What changed

A SchemaBinary Number spent eight bytes on every value, including 42 and 2. Attributing the benchmark payloads showed a third of the small record and 78% of the collections case going to f64 for values that are integral.

A Number now takes whichever of two forms is smaller, and the enclosing length says which:

  • eight bytes are the f64 form
  • one to seven bytes are a varint

Capping the varint at seven bytes is what keeps the two apart, and f64 is exact for integers well past that cap, so nothing is lost above it. Where no length is available the container defines the form: a uniform number array writes one mode byte and then a run in a single form, and a tuple slot keeps its length prefix.

The varint is sign-magnitude rather than plain zigzag: the low bit is the sign and the rest is the magnitude. That gives -0 a code of its own, so the varint form covers every integral JavaScript number instead of needing an f64 escape for the one value zigzag cannot express. NaN, the infinities, fractional values and integers past the cap stay f64.

When the encoded-side schema proves the value is an integer (Schema.Int, Schema.Natural, any isInt check) the layout drops the f64 form and writes a bare varint, so an integer array costs neither a mode byte nor a length prefix per element. A checked number and an unchecked one are different wire layouts for the same value: switching a field between Schema.Number and Schema.Int is a break, like any other field type change.

This is a deliberate wire change to an unstable, unreleased module.

Sizes

Case Before After Msgpack
small record 72 58 69
nested payload 347 318 385
collections 2553 1452 1462
large repeated records 31486 27646 51283

SchemaBinary is now ahead of Msgpack on all four payload sizes. Encode and decode throughput are unchanged within run-to-run noise; benchmark/schema/SchemaBinary.md carries the refreshed tables.

Verification

  • New public-API tests at every varint width through the seven-byte cap in both signs, the varint/f64 transition, -0, NaN, the infinities, fractional values, the safe-integer limits, and malformed number payloads, across structs, arrays, tuples, unions, the streaming parser, and schema-proven integer layouts.
  • A differential harness against c0945662b over 27 shapes and 400 randomized values each: both implementations decode to the same value, the new one round-trips exactly across the whole number domain, the streaming parser agrees with the one-shot codec, framing errors behave identically, and single-byte corruption introduces no new failure mode. Total corpus size fell 14.7%.
  • Repository lint, full typecheck, the 86 unstable encoding tests, and the full 8,675-test effect suite.

One pre-existing finding, unchanged by this commit and not fixed here: corrupting the int64 payload of a Schema.DateTimeUtc makes DateTime.makeUnsafe throw a RangeError instead of failing with a SchemaError.

🤖 Generated with Claude Code

A `Number` field spent eight bytes on every value, including `42` and `2`.
Attributing the benchmark payloads showed a third of the small record and 78%
of the collections case going to f64 for values that are integral.

A `Number` now takes whichever of two forms is smaller, and the enclosing
length says which: eight bytes are f64, one to seven bytes are a varint. The
varint is capped at seven bytes so the two can never be confused; f64 is exact
for integers well past that cap, so nothing is lost above it. Where no length
is available the container defines the form: a uniform number array writes one
mode byte and then a run in a single form, and a tuple slot keeps its length
prefix.

The varint is sign-magnitude rather than plain zigzag: the low bit is the sign
and the rest is the magnitude, which gives `-0` a code of its own instead of
needing an f64 escape for the one value zigzag cannot express.

When the encoded-side schema proves the value is an integer (`Schema.Int`,
`Schema.Natural`, any `isInt` check) the layout drops the f64 form and writes a
bare varint, so an integer array costs neither a mode byte nor a length prefix
per element. A checked number and an unchecked one are therefore different wire
layouts for the same value.

This is a wire change to an unstable, unreleased module. On the benchmark
cases the payloads go 72 -> 58, 347 -> 318, 2553 -> 1452 and 31486 -> 27646
bytes, which puts SchemaBinary ahead of Msgpack on all four sizes. Encode and
decode throughput are unchanged within run-to-run noise.

Verified with a differential harness against the previous head over 27 shapes
and 400 randomized values each: both implementations decode to the same value,
the new one round-trips exactly across the whole number domain, the streaming
parser agrees with the one-shot codec, framing errors behave identically, and
single-byte corruption introduces no new failure mode.
@changeset-bot

changeset-bot Bot commented Aug 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9999978

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 30 packages
Name Type
effect Major
@effect/opentelemetry Major
@effect/vitest Major
@effect/ai-anthropic Major
@effect/ai-openai-compat Major
@effect/ai-openai Major
@effect/ai-openrouter Major
@effect/atom-react Major
@effect/atom-solid Major
@effect/atom-vue Major
@effect/platform-browser Major
@effect/platform-bun Major
@effect/platform-deno Major
@effect/platform-node-shared Major
@effect/platform-node Major
@effect/sql-clickhouse Major
@effect/sql-d1 Major
@effect/sql-libsql Major
@effect/sql-mssql Major
@effect/sql-mysql2 Major
@effect/sql-pg Major
@effect/sql-pglite Major
@effect/sql-sqlite-bun Major
@effect/sql-sqlite-do Major
@effect/sql-sqlite-node Major
@effect/sql-sqlite-react-native Major
@effect/sql-sqlite-wasm Major
@effect/doctest Major
@effect/openapi-generator Major
@effect/docgen Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@effect-slopcop effect-slopcop Bot added enhancement New feature or request ready for review labels Aug 20, 2026
@tim-smart
tim-smart merged commit 265c3b0 into agent/codex-engineer/58cacc24 Aug 20, 2026
@tim-smart
tim-smart deleted the agent/claude-engineer/47bf5ba5 branch August 20, 2026 08:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request ready for review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant