skunkwerkx / hypercast
Allocation-free scalar parsing as Success/Fault verdicts — direct native FFI into a Rust core, no runtime bridge.
Requires
- php: >=8.1
- ext-ffi: *
Requires (Dev)
- phpbench/phpbench: ^1.7
- phpunit/phpunit: ^11.0
- squizlabs/php_codesniffer: ^3.10
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-04 20:05:44 UTC
README
Allocation-free parsers for scalars from untrusted text — booleans, numerics, UUIDs, and temporals. Every parse returns a Verdict: the value, or a closed reason code with the offending span. Never throws, never allocates. Written once in Rust, called directly from every host language, with a shared conformance corpus so every binding agrees byte for byte.
Every runtime already has TryParse. What it hands back is a bool and a shrug — no reason, no location, and none of the notations untrusted sources actually send. HyperCast's doors return a discriminated union instead: the value, or Empty / Malformed / OutOfRange plus the exact byte span that offended, so the error story is data, not archaeology. And because the engine is one Rust cdylib (libhypercast) called over a plain C ABI, the same logic — the same bit-for-bit verdicts — runs in every binding, proven by one corpus every implementation replays.
// C# (.NET 11+) — the verdict is a native union; an unhandled case is a compile error var message = Cast.Int32("(1,234)", NumFormat.From(culture)) switch { Success<int> s => $"got {s.Value}", // -1234, accounting negative Fault f => $"{f.Reason} at byte {f.Offset}", // no third case: the compiler checked };
// Rust — the core itself let verdict = hypercast::cast_timestamp(b"2026-01-02T15:04:05.123456789+05:00"); // Ok(Timestamp { seconds, nanos }) — protobuf's dual-integer form, normalized to UTC
The doors
| Door | Accepts | Comes out as |
|---|---|---|
| boolean | true/false, t/f, yes/no, y/n, 1/0, on/off, enabled/disabled, active/inactive, checked/unchecked, in/out — ASCII case-insensitive |
bool |
| integers i8–i64, u8–u64 | declared digit grouping, accounting parens (1,234), exponent 1e3, radix prefixes 0x/&H/0b as two's-complement bit patterns (0xFF is -1 for i8) — each lenience individually declarable; or NumFormat.DETECT, resolving the ./, roles structurally per input |
the type's own range, OutOfRange beyond it |
| reals f32/f64 | declared separators (eurozone 1.234,5, French NBSP grouping), parens, exponent, percent (50% ⇒ 0.5), a declared currency symbol at either edge ($1,234.50, -$5, ($5), 1.234,50 kr.) — finite values only; or NumFormat.DETECT: a repeated separator is grouping (1.234.567,89), with both present the rightmost is decimal, a non-3-digit right run is decimal (3,1415), a zero-led 0,785 is decimal — and the genuinely ambiguous (12.185, 1,000) is Malformed at the separator, never guessed |
IEEE, overflow-to-∞ is OutOfRange, NaN text is Malformed |
| decimal | the real doors' grammar — declared separators, grouping, parens, exponent, percent, currency, or NumFormat.DETECT — but no float is ever formed: 0.1 is one tenth, 50% is exactly 0.5, and the result is canonical (1.10 and 1.1 are the same value and come out the same — trailing fraction zeros trimmed, nothing else ever dropped) |
exact {magnitude: u96, scale: 0..=28, negative} — .NET decimal, BigDecimal, decimal.Decimal per binding; a magnitude or precision that cannot be represented is OutOfRange, never rounded |
| uuid | all five .NET Guid formats (D/N/B/P/X) plus urn:uuid: / GUID: / UUID: prefixes |
16 bytes, RFC 9562 order |
| timestamp | RFC 3339 with mandatory zone, normalized to UTC; separate Unix-epoch door with declared precision (s/ms/µs/ns — no magnitude guessing) | protobuf {seconds: i64, nanos: i32} — bindings present platform fidelity |
| date / time | strict yyyy-MM-dd (real calendar, leap days); separated dates (1/7/2026, 1.7.2026) under a caller-declared field order — Jan 7th or Jul 1st only because you said which, never guessed (a 4-digit first field is structurally a year, so ISO forms parse under any declared order), and undeclared slash dates stay Malformed; 24-hour HH:mm[:ss[.f≤9]] |
{y, m, d} / nanos-since-midnight |
| local datetime | the AM/PM world: <date> [<time>] — the declared-order date grammar plus an optional 24-hour or AM/PM time (1/7/2026 3:04 PM, 2026-01-07T15:04:05, 3 PM hour-only, 12 AM = midnight), no zone read and none invented — zone-less text names no instant, so zoned text is Malformed here and RFC 3339 stays the timestamp door |
civil {y, m, d} + nanos-of-day — LocalDateTime / DateTime(Unspecified) / naive datetime per binding; fusing a zone is the caller's job |
| excel serial | spreadsheet date serials under a caller-declared epoch (1900 or 1904 — a workbook-level setting no cell carries), whole part days and fraction time-of-day; the 1900 system's serial 60 is the 1900-02-29 that never existed (Lotus 1-2-3's leap-year bug, kept by Excel for file compatibility) and is Malformed, exactly as the text 1900-02-29 already is — so every serial past it is shifted one day, the arithmetic hand-rolled conversions get wrong |
protobuf {seconds, nanos} read as UTC — a cell carries no zone and none is invented |
| duration | ISO 8601 fixed components (P1DT6H30M15.5S — years/months rejected: not fixed durations), invariant colon form, protobuf JSON seconds (3.5s) — with ISO 8601's comma decimal mark accepted in all three shapes (PT1,5S, 0:00:01,5: durations have no grouping, so a comma can only be a decimal mark) |
protobuf {seconds, nanos}, ±10,000-year window |
Culture never lives in the core: numeric doors take a caller-declared format (separators, lenience flags, and a declared currency symbol — the one field a culture table has to fill in, so NumFormat.From(CultureInfo) copies the culture's own), separated dates take a caller-declared field order (DateOrder — the en-US/en-GB 1/7/2026 ambiguity is resolved by declaration, never sniffed), and each binding bridges its platform's culture machinery to both (NumFormat.From(CultureInfo) / DateOrders.From(CultureInfo) in C#, DateOrder.from(Locale) in Java, DateOrder.from(locale:) in Swift). Optionality is presentation: Empty is a verdict, and the optional doors map it to absent. And before the first cast, every binding can say whether the core loaded and which version it is (Cast.IsAvailable/NativeVersion in C#, and the same pair in each language's idiom), so a consumer with a fallback gates on a probe rather than catching a load failure.
Receipts — proven today, on this repo's own tests
-
Allocation-free is asserted by a counting allocator, not a doc comment —
rust/tests/allocation_free.rswraps#[global_allocator]around 1000 calls to every door, success and failure paths, and demands zero. A fault is a byte span into the caller's input; nothing is ever captured or formatted on the error path. -
The corpus is the contract —
corpus/*.json(seeded from the SvartalfheimNorse.Primitivestest suites this project descends from) replays through the Rust core's suite and every binding's. All eight replay the full thirteen-file set today — C# through real P/Invoke, Java through FFM downcalls, Ruby through both its Magnus and Fiddle backends. -
Published, to all five registries, and consumable from all eight languages — every binding is published and installable from its real registry, with the live version on each badge above: crates.io, nuget.org, PyPI (6 abi3 wheels), RubyGems (7 gems — one universal Fiddle, six precompiled Magnus, each fat across Ruby 3.4 and 4.0 since a Magnus extension is tied to one Ruby minor) and Maven Central; Go and Swift resolve from the tag itself (Go's prefixed
go/vX.Y.Z), PHP from Packagist. Trusted Publishing/OIDC wherever the registry offers it — no long-lived tokens for NuGet, RubyGems, or PyPI. Every one of the eight was then installed from its real registry into a clean project and run, because "the publish succeeded" and "a consumer can use it" are different claims: identical verdicts across all eight, and Java AOT plus C# AOT and Blazor wasm verified against the published artifacts rather than the working tree.Three of the four bugs this project has shipped were found exactly there, in the gap between those two claims, and none of them could fail a build in this repo. v0.0.1's first tag landed four of five registries: Maven died in our Gradle config, where
sourcesJarreadstageNativeLibrary's output without declaring the dependency — invisible to CI, which never builds a sources jar. Then v0.0.1's published artifacts turned out to be broken in two ways for AOT and wasm consumers specifically (see the Java AOT and WebAssembly notes below), which v0.0.2 fixes. The recovery protocol — gate the registries that accepted a version, fix the one that didn't, retag — is written intorelease.yml's header, because a version is only ever burned where it was actually accepted. -
Fast paths pay for the lenience — plain-shaped input takes allocation-free fast lanes; only text that actually uses the forgiveness pays for it. Measured with criterion against Rust's own best-in-class (linux-arm64):
cast_uuid15.8 ns against theuuidcrate's 11.8 ns,cast_i6415.5 vs 9.7 nsstr::parse,cast_timestamp30.3 vs 21.9 nstime. In-process against Rust's own parsers these doors trade raw speed for what they return (a verdict with a span) and what they accept; the speed story belongs to the bindings, where the competition is culture machinery. Correction on the record: an earlier version of this line claimed the UUID door beat theuuidcrate (15.4 vs 17.4 ns). Our number didn't move;uuid1.26 got faster. Receipts get re-run, and this one changed. -
Fuzzed, and it found real bugs — a
cargo-fuzztarget (rust/fuzz/) drives every door under every format profile and every declared order/precision/epoch, asserting two invariants every binding silently relies on: a door never panics on any byte sequence, and every fault span stays inside the caller's buffer (offset + len <= input.len(), which bindings slice with). It caught two real classes within a minute — truncation faults pointing one byte past the input, andchar_lenspans overrunning on text ending mid-UTF-8-character — both since fixed structurally and pinned byrust/tests/fault_span_invariant.rs(every corpus input truncated at every byte boundary, through every door) so they fail plaincargo test. The following 550M-execution session found nothing. -
WASM, for the core and for four of the bindings — the full Rust test suite (unit tests, the allocation proof, every corpus replay, the fault-span invariant sweeps) passes under
wasmtimeonwasm32-wasip1. No clock, no randomness, no dependencies: strictly easier freight than HyperUuid, whose wasm train this rides. And the samewasm32-wasip1module now ships inside the jar, the gems and the wheels and is committed undergo/native/, where GraalWasm (Java) and wasmtime (Ruby, Python, Go) run it in-process as a second backend behind each binding's existing switch — every one of those four suites, corpus replay included, runs a second time through it on every CI leg. See WebAssembly. -
C# binding on .NET 11, union-native —
Verdict<T>is a real discriminated union: two case arms, no default, and a missing disposition is a compile error (CS8509 as error). the whole suite green including the full corpus replay; source-generatedLibraryImportonly, and the AOT smoke test publishes underPublishAotinto a genuine native binary that runs every door — proven, not configured. -
C# vs. the BCL, first wave — BenchmarkDotNet,
[MemoryDiagnoser], lenience matched where the BCL has the knob (AllowThousands, invariant culture, UTC styles), FFI crossing and UTF-16→UTF-8 transcode included in every HyperCast number. Measured on linux-arm64, .NET 11 preview (in-process toolchain — BDN doesn't know the net11 moniker yet); zero managed allocation on every row, both sides:Door HyperCast BCL Verdict Cast.TimestampvsDateTimeOffset.TryParse71.0 ns 285.6 ns 4.0x faster Cast.DurationvsTimeSpan.TryParse64.1 ns 143.0 ns 2.2x faster Cast.Doublevsdouble.TryParse50.4 ns 69.8 ns 1.4x faster Cast.UuidvsGuid.TryParse54.8 ns 51.9 ns wash — and this door also takes N/B/P/X forms and urn:uuid:prefixesCast.Int32(grouped) vsint.TryParse+AllowThousands64.5 ns 54.0 ns 1.2x slower — the crossing tax, paid honestly Cast.Booleanvsbool.TryParse18.5 ns unmeasurable* honest loss — the BCL's five-byte compare wins; the twenty-lexeme vocabulary is why anyone calls this door * BDN flags the BCL boolean lane
ZeroMeasurement— the JIT hoists/foldsbool.TryParseof a loop-invariant string into nothing, which an FFI call structurally can't match. The loss is real either way and is printed as one.Read the table the way it's meant: the wins land exactly where the BCL runs culture machinery, the washes come while also carrying notations the BCL has no knob for at any price, and every number crosses a native boundary the BCL doesn't. Per-scalar this is parity-or-better; the round-three tabular layer crosses once per chunk and makes the same doors a landslide.
-
Java binding on JDK 22+, union-native the JVM way —
Verdict<T>is asealed interfaceover two records, so a two-arm switch with no default is proven exhaustive byjavac: an unhandled disposition is a compile failure, the same guarantee the C# binding gets from CS8509-as-error, in Java's own idiom. 31 tests green including the full corpus replay through real FFM downcalls with byte-exact fault spans; and full nanosecond fidelity —Instant/LocalTime/Durationkeep all nine fractional digits, making the JVM the one platform with zero truncation of what the core parses. -
Java AOT, proven — the GraalVM Native Image smoke test builds and runs every door plus the exhaustive union switch as a true native binary. Native Image needs the FFM downcall signatures and a resources glob registered, and the binding ships both in its
reachability-metadata.jsonso a consumer inherits them with zero configuration — the non-negotiable, delivered on both managed platforms. The resources half was missing from v0.0.1: a consumer's native binary built clean and died on first call with "classpath resource not found", while this repo's own smoke test stayed green because it declared the glob itself. That override is gone, so the test now proves the packaged metadata alone is sufficient. Found by building a real native image against the published jar, not against the repo. -
Java vs. the JDK — full-length, and the input no longer copies. 2 forks × (5 warmup + 10 measurement) iterations, 20 samples a row,
-prof gc:Cast.timestamp52.5 ± 1.7 ns vs 595.9 ± 16.8 nsInstant.parse(11.4x),Cast.time45.1 vs 393.4 ns (8.7x),Cast.duration60.9 vs 268.8 ns (4.4x),Cast.uuid38.3 vs 45.9 nsUUID.fromString— a loss in 0.1.0, a win now. What moved the numbers, twice: 0.1.0 removed theArena.ofConfined()every door opened per call; 0.2.0 removed the copy that was left — every downcall is linkedLinker.Option.critical(true), so the caller'sbyte[]crosses as a pinned heap segment instead of being copied into a per-thread native staging buffer, every door gained aMemorySegmentoverload for casting a slice of one buffer with nothing copied, and the UUID door reads two big-endian longs instead of sixteen bytes. One row still loses and is printed as such:Boolean.parseBooleanis unbeatable by construction (the JIT folds a loop-invariant call to nothing). -
The full seven-binding roster, corpus-green — Python (PyO3 native extension,
match/caseover the two verdict types), Swift (dlopen+@convention(c), and the strongest union in the roster — a real enum where exhaustive switch is compiler-mandatory, no opt-in flag), Go (dual backend: cgo on darwin/linux, purego everywhere else including Windows and everyCGO_ENABLED=0cross-compile — the(value, *Fault)idiom with*Faultaserror), Ruby (Fiddle fallback + Magnus extension, pattern-matchedDataclasses with Symbol reasons), and PHP (ext-ffi,Success|Faultunion types over a backed enum). Every one replays every corpus file with byte-exact fault spans — every suite green on this machine today — and every one presents its platform's honest fidelity: Ruby and the JVM keep every nanosecond (Ruby's durations are exactRationalseconds across the whole ±10,000-year window), Python and PHP truncate to microseconds and say so, Swift'sDurationis attosecond-backed, and Go returns the protobuf pair becausetime.Duration's ±292-year ceiling can't hold the window — stated, not wrapped. -
Benchmarks across the whole spectrum — each binding carries its ecosystem's own harness, HyperUuid-style: Criterion, BenchmarkDotNet, JMH,
testing.B, pyperf, benchmark-ips, phpbench, and ordo-one's package-benchmark. The spine of the story, the RFC 3339 timestamp door vs. each platform's own parser (linux-arm64, first-wave numbers, crossing and transcode included in every HyperCast figure):Binding HyperCast Platform parser Verdict C# 76.9 ns ( string), 51.2 ns (UTF-8)282 ns DateTimeOffset.TryParse3.7x / 5.5x faster Java 52.5 ns ( String), 46.2 ns (byte[])596 ns Instant.parse11.4x faster (full-length run) Swift 55 ns (was 281 in 0.1.0) 817 ns Date.ISO8601FormatStyle14.9x faster — uuid too: 37 vs 603 ns, zero mallocs on every door Go 112 ns, 0 allocs (was 174 ns, 2 allocs) 67 ns time.Parse(RFC3339Nano)honest loss — Go's stdlib RFC 3339 path is exceptional, and every Go door still loses per-call to cgo's crossing, now without a heap allocation on top Ruby (Magnus backend) 771 ns 3.32 µs Time.iso86014.3x faster — see below Ruby (Fiddle fallback) 3.2 µs — parity with Time.iso8601, atop the measured 1.6 µs Fiddle floor; kept as the zero-compile pathPython (PyO3) 201 ns 163 ns fromisoformat(C-accelerated)near-parity — see below Python (retired ctypes) 3.1 µs — the measured ~1 µs ctypes floor — the before-picture that justified going PyO3-only PHP 487 ns 1.3 µs DateTimeImmutable2.7x faster — no new mechanism needed, just the wrapper diet the 105 ns ext-ffi floor demanded Python's escape from the interpreted tier is its own receipt: the losses were never "Python calling native code" — they were ctypes (interpreted marshalling, ~1 µs/call, measured). The PyO3 extension (
hypercast._native) is the Rust core linked directly into a CPython extension — no dlopen, no C-ABI hop, the sameMETH_FASTCALLdoor the builtins walk — and after the mechanism swap proved out, the ctypes fallback was retired entirely, HyperUuid-style: the abi3 wheels maturin builds are the package, one per platform covering every CPython 3.10+, no compiler needed to install (the ctypes rows above stand as the measured before-picture). Result: every door 10–18x faster — timestamp 3.07 µs → 201 ns, i32 → 146 ns vsint()'s 88, uuid 730 ns against 1.18 µs before 0.2.0 — it now buildsuuid.UUIDthe way HyperUuid pinned,UUID.__new__plusobject.__setattr__, skipping the__init__that had bounded both sides — ahead ofuuid.UUID()'s 979 ns, and the forgiveness doors at ~180 ns for grammar the stdlib doesn't sell.The honest reading, after the redemption arc: every language in the roster now beats its own platform's culture-machinery parser except Go — whose stdlib is simply excellent and whose per-call story waits for the batch layer. The interpreted tier's losses were never the languages; they were the FFI mechanisms, measured then replaced: Python got a PyO3 extension (no mechanism left to pay), Ruby got a Magnus extension (Fiddle's 1.6 µs floor gone — the doors now beat
Time.iso8601by 4.3x while returning exactRationaldurations), and PHP needed no new mechanism at all — its ext-ffi floor was 105 ns all along, so a wrapper diet (flat doors, typed cdef structs, static scratch,createFromTimestamp) took timestamp from 2.9 µs to 487 ns. Ruby keeps Fiddle as its zero-compile fallback (HYPERCAST_PURE=1forces it; both backends replay the corpus green, and a cross-backend agreement spec pins them together), with precompiled platform gems as the vehicle for shipping Magnus without ever making a consumer compile. Benchmarking sagas worth knowing: the Ruby doors were 4.3 µs until per-callFiddle::Pointer.mallocfinalizers were hoisted to thread-local scratch, PHP read 20x slow until a loaded Xdebug was caught (XDEBUG_MODE=offfor all recorded numbers), and Swift's first tape was pure measurement-floor quantization until.kiloscaling amortized it — receipts include their own forensics. -
The messy-feed doors, cross-binding — the declared-order date/date-time doors exist for text with no stdlib parser at all, so each row pairs against whatever that platform does offer for the same string (
1/7/2026 3:04 PM): a pattern formatter, a culture-awareTryParse, orstrptime. Same machine, same run, linux-arm64:Binding HyperCast Platform's closest parser Verdict Python 409 ns 5.19 µs datetime.strptime12.7x faster Java 64.9 ns 386.2 ns DateTimeFormatter(M/d/yyyy h:mm a)6.0x faster C# 61.2 ns 222.9 ns DateTime.TryParse(en-US)3.6x faster Swift 129 ns 28 µs DateFormatter(same pattern, hoisted — measured at 810 ns on 0.1.0's toolchain, 28 µs on Swift 6.3.3 today; see swift/README)faster by two orders on this toolchain PHP 620 ns 1.36 µs DateTimeImmutable::createFromFormat2.2x faster Go 122 ns, 0 allocs 135 ns time.Parsew/ layoutparity — the by-value shims closed the gap that was a loss in 0.1.0 Ruby 1.30 µs 1.02 µs DateTime.strptimehonest loss — see below Ruby's loss is about the carrier, not the parse. Its timestamp door is 4x faster than
Time.iso8601on the same backend; the civil door is slower because building a stdlibDateTimewith an exactRationalsecond costs more than the entire native call, whereTimeis one cheaprb_time_nano_new. (Same reason itsDateTimeshows a+00:00offset: a property of the type, not a zone the parse assigned.) Printed because it's real — house rules.Separator detection costs what the core says it costs, once the carrier is thin enough to see it.
NumFormat.DETECTresolves./,roles structurally per input at ~11 ns in the raw Rust core. In 0.1.0 that vanished inside every binding's per-call overhead (Java 105.1 vs 106.0 ns declared, Swift 399 vs 406); with 0.2.0's carriers it shows: Java 100.1 vs 86.5 ns, Swift 86 vs 74, Go 172 vs 164, Ruby (Magnus) 591 vs 570 — both of those Ruby numbers down from ~990, since any format other thanINVARIANTused to pay three method dispatches per call; C# ~6 ns, PHP ~22 ns, Python ~18 ns.
Provenance
Every published artifact across all eight bindings — the package itself where a registry
has one, and the native binaries underneath it either way — carries a GitHub build-provenance
attestation, checkable with gh attestation verify. Which flags that needs depends on where
the signing workflow physically lives, not on which registry the artifact ended up in:
artifacts signed directly inside this repo's own release.yml — the RubyGems gems, the PyPI
wheels, and the published NuGet package — verify with plain --repo SkunkWerkx/HyperCast.
Artifacts signed by a reusable workflow hosted in SkunkWerkx/.github — the crates.io crate,
the Maven jar, the pre-push NuGet package, every native library (which is the entire
story for Go, Swift, and PHP, none of which has a package-level attestation of its own), and
the wasm32-wasip1 module that rides inside the jar, the gems, the wheels and go/native/ —
need --signer-repo SkunkWerkx/.github added, or --owner SkunkWerkx in place of both
flags. Get it wrong and gh reports a bare verifying with issuer "sigstore.dev", which
reads like a bad signature but is only an identity mismatch.
The gates run on the way in, not just on the way out: stage-native-binaries.yml verifies
each native library's attestation before committing it for the Go/Swift/PHP consumers, the
RubyGems job verifies every native artifact it packs before building a gem, and both the
crate and the gems are attested before their irreversible push — a signing failure stops
the release while it can still be retried. See each binding's own README for its exact
verify command and artifact: Rust,
C#, Java,
Ruby, Python,
PHP, Swift,
Go.
WebAssembly
WebAssembly meets a binding in one of two directions, and they share nothing mechanically:
- The core runs as wasm inside the binding. The process stays native. The Rust core
arrives as a
wasm32-wasip1module,hypercast.wasm, and a wasm engine the ecosystem already has runs it in-process: nodlopen, no per-platform binary, the same C-ABI exports. The engine is an optional dependency the consumer adds only if they want this path. - The binding is compiled to wasm. The whole consumer app becomes a wasm module
(Blazor, a
wasm32Rust crate) and the Rust core has to be linked into that build by the ecosystem's own toolchain.
Where each of the eight stands, today:
| Binding | Core as wasm inside the binding | Binding compiled to wasm |
|---|---|---|
| Rust | Not applicable: the crate is the core, and the wasip1 build of it is the module everyone else embeds. | Yes. The full suite passes under wasmtime on wasm32-wasip1; no clock, no entropy, nothing to stub. See rust/README.md. |
| C# | Not built. | Packaged and linked, not yet run in a browser. dotnet add package HyperCast into a Blazor WebAssembly project links the staticlib and exports all 20 doors through the shipped .targets; see csharp/README.md and the aspirations below for what is still owed. |
| Java | Yes. GraalWasm; -Dhypercast.backend=wasm, or automatic when the jar has no native build for the platform. |
Blocked. No Java-to-wasm compiler supports the Foreign Function & Memory API this binding is built on: GraalVM's Web Image (--tool:svm-wasm) is labeled experimental and never lists it, and neither TeaVM nor CheerpJ has it. |
| Ruby | Yes. wasmtime gem; HYPERCAST_WASM=1, or automatic when no native library exists for the platform. |
Not tried. ruby.wasm has no runtime library search, so the Fiddle backend cannot work there; it does link C extensions statically at build time, and building a ruby.wasm with the Magnus extension linked in has not been attempted here. |
| Python | Yes. wasmtime-py (pip install hypercast[wasm]); HYPERCAST_WASM=1, or automatic when the PyO3 extension fails to import. |
Proven once, then removed. The core built as an Emscripten side module loaded through ctypes.CDLL in a real Pyodide session; that smoke test existed to justify the ctypes backend and went with it when PyO3 abi3 wheels made the fallback unnecessary. |
| Go | Yes. wasmtime-go; -tags hypercast_wasm, opt-in only, never selected automatically. cgo throughout, so no win-arm64. |
Blocked. cgo has no wasm target, purego's supported-platform list has no wasm entry (its whole model is runtime dlopen), and go:wasmimport/go:wasmexport let a Go module talk to its host, not link a second module. |
| Swift | Not built. No wasm engine ships as a Swift package with a stable API, so there is nothing to embed. | Blocked. swift.org ships official WASM SDKs since Swift 6.2, but its own docs say dynamic linking "is not formally specified for wasip1 triples and tooling for it is not available yet," and no static path to a Rust .a is documented either. |
| PHP | Not built. There is no maintained wasm engine PHP can embed. | Blocked. The maintained wasm PHP (WordPress Playground's @php-wasm) loads extensions at build time or startup only, and there is no indication the FFI extension this binding needs is available there at all. |
The two directions are blocked, where they are blocked, for different reasons. Compiling a
binding to wasm needs the ecosystem's toolchain to link a Rust static library into its own
wasm build. .NET has a supported mechanism for exactly that (NativeFileReference, which this
package's .targets injects for you); Swift, PHP and Go do not. Java's gap is different in
kind: the loading mechanism is not the problem, the compilers that exist have no FFM. The
in-process backends sidestep all of that rather than climb it, because the engine is the
loader, and they are what a platform with no native build falls back to.
The in-process backends
One artifact, hypercast.wasm, built from the same crate with wasi-libc's malloc/free
exported (two linker flags in rust/.cargo/config.toml, no source change), ships beside the
native libraries in the jar, the gems and the wheels, and is committed under go/native/
like the rest of Go's binaries. CI builds it on every leg and runs the Java, Ruby, Python and
Go suites a second time through it (Go on the non-Windows legs, since wasmtime-go has no
win-arm64 build). Every number below was measured through the shipped binding on one
linux-arm64 box (WSL2) in one session, native column beside it; each binding's README has
the mechanics, the exact loop, and more doors.
| Binding | Engine dependency | i32, one call |
timestamp, one call |
Native, same box |
|---|---|---|---|---|
| Java | org.graalvm.polyglot:wasm, compileOnly, never in the POM |
237 ns (grouped) on GraalVM CE 25.3 (JIT); 20.1 µs on Temurin 25 (interpreter) | 354 ns (JIT); 8.5 µs (interpreter) | 66 ns / 60 ns |
| Ruby | wasmtime gem, a Gemfile group for the suite, never a dependency of the gem |
2.65 µs | 3.10 µs | 555 ns / 769 ns (Magnus); 2.78 / 3.12 µs (Fiddle) |
| Python | wasmtime, via the [wasm] extra |
6.4 µs | 7.7 µs | 139 ns / 423 ns |
| Go | wasmtime-go, compiled in only under the tag | 3.4 µs | 3.8 µs | 84 ns / 101 ns |
Three footnotes to those rows. On a stock JDK GraalWasm has no JIT and runs the module interpreted, with a startup warning, so its cost scales with how much wasm the parse executes; under GraalVM's JIT the same doors sit at 4-10x the FFM downcall, and the wasm path also survives a GraalVM Native Image build on the jar's own reachability metadata (the Java README has the receipts). Ruby's wasm backend lands at Fiddle parity door for door, which makes it a free fallback there. And unlike HyperUuid there is no batch door anywhere in this crate to amortize the crossing behind — a per-cell workload pays it per cell — so these backends are portability answers, not speed options, until round three's chunk layer crosses once per chunk.
Two facts every one of those four shares, both learned the hard way in HyperUuid. The host
must take its buffers from the guest's own allocator: a host-picked offset past the data
segments looked free and was not, because dlmalloc claims the tail of the initial memory on
first use, and the next allocation overwrote a buffer mid-way. And every call is serialized
under a lock, because neither a GraalWasm Context nor a wasmtime Store is safe for
concurrent use; the native backends stay lock-free.
Aspirations — the queue that turns into receipts
Stated the way this project states things: each of these becomes a measured table or a CI matrix row, or it gets cut. Details in docs/roadmap.md.
- Per-binding benchmark passes for the rest of the roster — Java's is done (the scratch-arena pass landed and the full-length JMH run replaced its directional table), and every new door now carries numbers. What's left is the same discipline applied to the remaining first-wave figures: no number enters this file from a rushed run, and re-runs get published even when they go the wrong way — see the
uuid-crate correction above. - The wasm leg beyond the core — the packaging half is done and proven from a real consumer: a Blazor app that only adds a
PackageReferencelinks the staticlib and exports all 20 doors, via thebuild/net11.0/HyperCast.targetsthe package now ships. What's left is running that app in an actual browser session and reporting numbers from it; a successfulwasm-ldlink is not the same claim as working in a browser, and this project doesn't get to call it proven until it has been. The other direction — the core as wasm inside a native process — is built for Java, Ruby, Python and Go (see WebAssembly); Pyodide left with the ctypes backend. - The payoff: tabular ingestion — CSV/TSV/delimited and XLSX parsing on top of these doors, so the FFI boundary is crossed once per chunk instead of once per cell. A million-row, 20-column file is 20M scalar casts; per-cell that's real crossing overhead, per-chunk it rounds to zero while 15–35 ns doors run in a tight native loop. HyperUuid already measured this exact amortization at 19.6x on its batch API. Column buffers in, parallel verdict arrays out — the reason every fault is a span and never an allocation. Under way in three repositories of their own — HyperTabular (the contract), HyperDelimited, and HyperWorkbook — each with a Rust crate green against this core; bindings and the corpus are what remain.
Non-negotiables, every round: full AOT in .NET and Java; wasm ride-along for the core and bindings; the tabular layer is server-domain (AOT yes, wasm out of scope there, by design).
Layout
corpus/ the shared conformance vectors — the cross-language contract
rust/ the core: one cdylib, 20 cast_* exports, zero runtime dependencies
csharp/ the .NET 11 binding: Verdict<T> union, LibraryImport, corpus replay, AOT smoke test
java/ the JDK 22+ binding: sealed-interface union, FFM + GraalWasm backends, corpus replay, Native Image smoke test
python/ the 3.10+ binding: match/case verdicts, PyO3 native extension (abi3 wheels) + wasmtime backend
swift/ the SwiftPM binding: enum verdicts (mandatory-exhaustive switch) over dlopen
go/ the Go binding: (value, *Fault) verdicts, cgo + purego + wasmtime-go backends
ruby/ the 3.2+ binding: pattern-matched Data verdicts, Fiddle + Magnus + wasmtime backends
php/ the 8.1+ binding: Success|Fault union types over ext-ffi
docs/ roadmap and parked designs — where this goes, and what's deliberately not built yet
Why "Hyper"
The SkunkWerkx Hyper* series — HyperUuid, HyperCast — owes its founding attitude to Casey Muratori and his recent YouTube talks on what "premature optimization" actually meant. Knuth's line gets quoted as a license to never care; Muratori's point is that most slow software was never optimized badly — it was pessimized by default: allocations nobody needed, layers nobody asked for, work done and thrown away on every call. These libraries are that argument, practiced: allocation-free cores, no runtime bridge, no reflection, fast paths for the common shape — and every performance claim a measured receipt, because the other half of taking performance seriously is refusing to assert it.