hypercast 0.2.0-x86_64-linux → 0.4.0-x86_64-linux

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fc3a5470a5e3d1e1f9ce8309684198ff2305059590e4172652bc96696cb4615b
4
- data.tar.gz: 84d54474ec68f588d874acdb8ed6810e1f4f72c701f7ddbdc6f95e75396e0712
3
+ metadata.gz: 39c6dc5065dbf206aa1760bf0385ea8bea8f131f11dea3489d92423614393e13
4
+ data.tar.gz: fbcb0af9091c853eebdb56aa1c85fb229844c52cb9995281396ad004f5500ae5
5
5
  SHA512:
6
- metadata.gz: 75c2f355db00c613b7ee864ef3e12e2aa2c2136912fba35bce85f52e10845f174990c69bf0b2cf09c6e8c63fece92c5aff9f0239ac00cedf7b292ef8ebde827d
7
- data.tar.gz: ee2aa5a6a2bca4b99ab0e5125c490eb25bf8f80349e2ebda9a43d52850afa990cea80af0a72837d5cf51a24e0e072a29f9a3fa428afe7d6aa5841ffd09eeda68
6
+ metadata.gz: 74f52267197d0934ff760441a2be4e4435891bb067bb6357f60c378a7a00bc58d52d53307698f9ba2e65e8a380596bbb7c2a32fb6701682f4c4a5335ae60269f
7
+ data.tar.gz: 9701f13462c868a190ab02f3acbd9e43acc71d30c4684a3160c291b22427bbbfaac6b890308468ae4a1283908250cfa494157aa3007aa874f40ce364ef148c74
data/README.md CHANGED
@@ -1,26 +1,27 @@
1
1
  # hypercast
2
2
 
3
3
  [![CI](https://github.com/SkunkWerkx/HyperCast/actions/workflows/ci.yml/badge.svg)](https://github.com/SkunkWerkx/HyperCast/actions/workflows/ci.yml)
4
- [![Gem](https://img.shields.io/gem/v/hypercast.svg)](https://rubygems.org/gems/hypercast)
4
+ [![RubyGems](https://img.shields.io/gem/v/hypercast.svg)](https://rubygems.org/gems/hypercast)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/SkunkWerkx/HyperCast/blob/master/LICENSE)
6
6
 
7
7
  **Ruby's own pattern matching over two `Data` case types — the value, or a closed reason
8
- Symbol plus the exact byte span that offended. Two backends, one public surface: a Magnus
8
+ Symbol plus the exact span that offended. Three backends, one public surface: a Magnus
9
9
  native extension where a precompiled platform gem covers you, stdlib Fiddle everywhere
10
- else — selected automatically, zero compiles either way.**
10
+ else — selected automatically, zero compiles either way — and the same core as a
11
+ WebAssembly module inside the `wasmtime` gem for any platform with no native build at all.**
11
12
 
12
- Allocation-lean scalar casts — booleans, the full integer family, reals, UUIDs, temporals —
13
- calling directly into the native `libhypercast` Rust core. Ruby 3.2 is the floor
14
- (`Data.define` for the verdict case types). The fast path links the core straight into a
15
- Ruby extension (Magnus): on require it redefines the doors in place on the `HyperCast`
16
- module — no delegation layer, no second surface, which is exactly what keeps the backends
17
- provably in agreement. `HyperCast::BACKEND` reports which is live; `HYPERCAST_PURE=1`
18
- forces Fiddle.
13
+ Allocation-lean scalar casts — booleans, the full integer family, reals, exact decimals,
14
+ UUIDs, temporals — calling directly into the native `libhypercast` Rust core. Ruby 3.3 is the
15
+ floor. The fast path links the core straight into a Ruby extension (Magnus): on require it
16
+ redefines the doors in place on the `HyperCast` module — no delegation layer, no second
17
+ surface, which is exactly what keeps the backends provably in agreement.
18
+ `HyperCast::BACKEND` reports which is live; `HYPERCAST_PURE=1` forces Fiddle,
19
+ `HYPERCAST_WASM=1` the wasm module — see [Backends](#backends).
19
20
 
20
21
  ```ruby
21
22
  case HyperCast.i32("(1,234)", HyperCast::NumFormat::INVARIANT)
22
23
  in HyperCast::Success(value:) then puts "got #{value}" # -1234
23
- in HyperCast::Fault(reason:, offset:) then puts "#{reason} at byte #{offset}"
24
+ in HyperCast::Fault(reason:, offset:) then puts "#{reason} at #{offset}"
24
25
  end
25
26
  ```
26
27
 
@@ -28,8 +29,129 @@ Door names mirror the native ABI (`i32`, `f64`, `timestamp`, …). Ruby-flavored
28
29
  stated proudly — nothing the core parses is lost on the way out: `Integer` is
29
30
  unbounded (u64 comes back as the true unsigned value), `Time` carries full nanoseconds
30
31
  across the whole 0001–9999 window, time-of-day is an exact Integer of nanoseconds since
31
- midnight, and durations come back as exact `Rational` seconds across the core's whole
32
- ±10,000-year window — no truncation anywhere, no wrapping.
32
+ midnight, durations come back as exact `Rational` seconds across the core's whole
33
+ ±10,000-year window, and `decimal` returns an exact `HyperCast::Decimal` that never rounds
34
+ — no truncation anywhere, no wrapping.
35
+
36
+ ## The doors
37
+
38
+ | Door | Value | Declares |
39
+ |---|---|---|
40
+ | `bool(text)` | `true`/`false` | — |
41
+ | `i8` `i16` `i32` `i64` `u8` `u16` `u32` `u64` `(text, format)` | `Integer` (unbounded — u64 is the true unsigned value) | a `NumFormat` |
42
+ | `f32` `f64` `(text, format)` | `Float` (f32 widened losslessly) | a `NumFormat` |
43
+ | `decimal(text, format)` | `HyperCast::Decimal` — exact sign, 96-bit magnitude, base-10 scale | a `NumFormat` |
44
+ | `uuid(text)` | lowercase hyphenated `String` (`SecureRandom.uuid`'s shape) | — |
45
+ | `timestamp(text)` | UTC `Time`, full nanoseconds | — |
46
+ | `unix(text, precision)` | UTC `Time` | `:seconds` / `:milliseconds` / `:microseconds` / `:nanoseconds` |
47
+ | `excel_serial(text, epoch)` | UTC `Time` | `:y1900` / `:y1904` |
48
+ | `date(text, order = nil)` | `Date` | strict ISO, or `:year_month_day` / `:month_day_year` / `:day_month_year` |
49
+ | `datetime(text, order)` | zone-less `DateTime`, exact `Rational` seconds | a field order, as `date` |
50
+ | `time(text)` | `Integer` nanoseconds since midnight | — |
51
+ | `duration(text)` | exact `Rational` seconds | — |
52
+
53
+ Every door returns a `Success` or a `Fault`; `HyperCast.optional(verdict)` folds `:empty`
54
+ to `nil`. Beside the doors, `native_version` returns the loaded core's
55
+ `"major.minor.patch"` and `available?` answers `true`/`false` without ever raising — see
56
+ [Is the native core there?](#is-the-native-core-there).
57
+
58
+ ### Errors
59
+
60
+ Bad data is never an exception — it is a `Fault`. What raises is a caller bug, and it raises
61
+ the same exception on every backend:
62
+
63
+ - `ArgumentError` from `NumFormat.new` for a malformed format: separators that are not single
64
+ characters or not distinct, a currency symbol that is too long or carries an ASCII digit or
65
+ whitespace.
66
+ - `KeyError` — `Hash#fetch`'s own, `key not found: …` — for a precision, epoch or date order
67
+ that names nothing: an unknown Symbol, or anything that is not a Symbol (`"seconds"`
68
+ included). `date(text, nil)` is not one of these: an explicit `nil` order is the strict ISO
69
+ door, exactly as if it were left off.
70
+ - `TypeError` for text that is not a `String` (anything with `to_str` is taken as one).
71
+ - `String#encode`'s own `Encoding::` error for text in another encoding that cannot be
72
+ transcoded to UTF-8.
73
+
74
+ ### Is the native core there?
75
+
76
+ `HyperCast.available?` answers whether a backend actually loaded and exports the ABI this
77
+ binding was built against — probed once, cached, never raising — for a consumer with a
78
+ fallback of its own:
79
+
80
+ ```ruby
81
+ value = HyperCast.available? ? HyperCast.i32(text, format) : my_own_parse(text)
82
+ ```
83
+
84
+ `HyperCast.native_version` reads the version out of the loaded core itself (its
85
+ `hypercast_version` export — the cheapest probe that the backend resolved at all), so a
86
+ mismatch against `HyperCast::VERSION` can be named before the first cast.
87
+ `HyperCast::BACKEND` says which backend was *selected*, which is not the same question: it
88
+ reads `:fiddle` on a platform with no library at all, because that is the backend whose
89
+ first call explains what is missing — a `LoadError` naming the path it looked for, or
90
+ `HyperCast::NativePlatform::UnsupportedPlatformError` naming the platform.
91
+
92
+ ### Fault spans index the String you passed
93
+
94
+ A `Fault`'s `offset`/`length` are in the units `String#[]` slices by on your own input:
95
+ character offsets for text — in any encoding; the core reads UTF-8, and the span is mapped
96
+ back, an identity for ASCII — and byte offsets for a binary (`ASCII-8BIT`) String, whose
97
+ characters are its bytes. Either way `text[offset, length]` is the offending text with no
98
+ arithmetic on your side:
99
+
100
+ ```ruby
101
+ HyperCast.i32("1€", HyperCast::NumFormat::INVARIANT) # => Fault(reason: :malformed, offset: 1, length: 1)
102
+ HyperCast.i32("1€".b, HyperCast::NumFormat::INVARIANT) # => Fault(reason: :malformed, offset: 1, length: 3)
103
+ ```
104
+
105
+ The mapping happens only on the failure path, so a `Success` never pays for it.
106
+
107
+ ### `decimal`: exact, never rounded
108
+
109
+ `HyperCast::Decimal` is a `Data` of `magnitude` (an unbounded `Integer`, at most 2⁹⁶ − 1),
110
+ `scale` (0–28: places the magnitude is shifted right) and `negative` — the shape .NET's
111
+ `decimal` stores and `BigDecimal` builds from directly. No float is ever formed, so `"0.1"`
112
+ is one tenth and `"50%"` is exactly `0.5`. The triple is canonical: exact trailing zeros in
113
+ the fraction are always trimmed, so the scale is minimal — `"1.10"`, `"1.1"` and `"1.1000"`
114
+ are all magnitude 11, scale 1, while `"100"` stays magnitude 100, scale 0 — and zero is
115
+ scale 0 and never negative. Nothing but a zero is ever dropped: text carrying more precision
116
+ than 96 bits and 28 places can hold is an `:out_of_range` `Fault`, not an approximation.
117
+
118
+ ```ruby
119
+ value = HyperCast.decimal("-1,234.50", HyperCast::NumFormat::INVARIANT).value
120
+ value.to_s # => "-1234.5" (the canonical text, every binding renders the same)
121
+ value.to_r # => (-2469/2)
122
+ value.to_d # => BigDecimal("-1234.5")
123
+ ```
124
+
125
+ `to_d` requires the `bigdecimal` gem lazily on first call — a bundled gem since Ruby 3.4,
126
+ deliberately not a dependency of this one, so under Bundler add it to your own Gemfile.
127
+
128
+ ### `NumFormat`: declared separators, and a declared currency
129
+
130
+ ```ruby
131
+ HyperCast::NumFormat.new(decimal_sep: ".", group_sep: ",", flags: HyperCast::ALL_STYLES, currency: "$")
132
+ ```
133
+
134
+ The flags are `GROUPING`, `PARENTHESES`, `EXPONENT`, `RADIX_PREFIXES`, `PERCENT` and
135
+ `CURRENCY` (`ALL_STYLES` is all six), plus the `SEPARATOR_DETECT` policy. The currency
136
+ symbol is the one field a culture table would fill in: it is declared, never looked up, and
137
+ honored only while `CURRENCY` is set — once, leading (`$5`, `-$5`, `$ -5`) or trailing
138
+ (`5 €`, `1.234,50 kr.`), with optional ASCII whitespace between symbol and digits, and
139
+ accounting parentheses wrapping symbol and digits together (`($5)`). Declared with the
140
+ flag off, the symbol is a `:malformed` `Fault` at the symbol; the flag with nothing declared
141
+ (`currency: ""`, the default — `INVARIANT` and `DETECT` declare none) matches nothing. A
142
+ symbol longer than 16 UTF-8 bytes, or carrying an ASCII digit or ASCII whitespace, is an
143
+ `ArgumentError` at construction, the same caller-bug treatment equal separators get.
144
+
145
+ ```ruby
146
+ dollars = HyperCast::NumFormat.new(decimal_sep: ".", group_sep: ",", flags: HyperCast::ALL_STYLES, currency: "$")
147
+ HyperCast.i32("($1,234)", dollars) # => Success(value: -1234)
148
+ HyperCast.decimal("$1,234.50", dollars) # => Success(value: Decimal(magnitude: 12345, scale: 1, negative: false))
149
+ HyperCast.i32("$5", HyperCast::NumFormat::INVARIANT) # => Fault(reason: :malformed, offset: 0, length: 1)
150
+ ```
151
+
152
+ Across the ABI a `NumFormat` is a 32-byte struct — the two separators as code points, the
153
+ flags, and the symbol's length and UTF-8 bytes held inline — packed once per format object
154
+ and memoized by identity on every backend, so declaring a currency costs a cast nothing.
33
155
 
34
156
  ## Why not `Integer()` / `Time.iso8601` / `Float()`?
35
157
 
@@ -39,19 +161,18 @@ midnight, and durations come back as exact `Rational` seconds across the core's
39
161
  parentheses, declared separators, radix prefixes, all five .NET `Guid` text forms plus
40
162
  `urn:uuid:` prefixes, protobuf JSON durations.
41
163
  3. **One engine across a polyglot system** — bit-for-bit verdicts with every other binding,
42
- held by the shared corpus (24 examples green on *both* backends, full twelve-file corpus
43
- replay; a cross-backend agreement spec compares Magnus and Fiddle outputs across a
44
- subprocess boundary).
164
+ held by the shared corpus (the whole suite green on *all three* backends, full
165
+ corpus replay; cross-backend agreement specs compare Magnus against Fiddle
166
+ and wasm against Fiddle across a subprocess boundary).
45
167
  4. **Faster than the stdlib on the Magnus backend, where the carrier is cheap** —
46
- benchmark-ips (`ruby benchmark/cast_benchmark.rb`, linux-arm64): timestamp **713 ns vs
47
- 2.88 µs `Time.iso8601`** (4.0x) — while returning exact `Rational` durations on the
48
- duration door. The Fiddle fallback lands at ~3.5 µs: parity with `Time.iso8601`, sitting
49
- on Fiddle's measured 1.6 µs per-call marshalling floor.
50
-
51
- Separator detection is free here: `1.234.567,89` under `NumFormat::DETECT` runs at
52
- 591 ns against 570 ns for the same text under a declared eurozone format — inside the
53
- error bars. Both of those were ~990 ns in 0.1.0, for a reason that had nothing to do
54
- with parsing: every format other than `INVARIANT` paid three method dispatches and two
168
+ benchmark-ips (`ruby benchmark/cast_benchmark.rb`, linux-x64 on an Intel Core
169
+ i9-11900H, Ruby 4.0.7): timestamp **447 ns vs 2.97 µs `Time.iso8601`** (6.6x) — while
170
+ returning exact `Rational` durations on the duration door. The Fiddle fallback lands at
171
+ 3.25 µs: a little behind `Time.iso8601`, sitting on Fiddle's per-call marshalling floor.
172
+
173
+ Separator detection is nearly free here: `1.234.567,89` under `NumFormat::DETECT` runs
174
+ at 178 ns against 170 ns for the same text under a declared eurozone format. Both cost
175
+ nearly twice that in 0.1.0, for a reason that had nothing to do with parsing: every format other than `INVARIANT` paid three method dispatches and two
55
176
  `String` allocations per call to read its separators back out of the `Data`. `DETECT`
56
177
  is now identity-matched like `INVARIANT`, and any other format is resolved once per
57
178
  thread and memoized by identity — anchored in a thread-variable so the memo's key can
@@ -60,29 +181,129 @@ midnight, and durations come back as exact `Rational` seconds across the core's
60
181
  cached the same way, so a fault or a declared option is a pointer compare too, never a
61
182
  `Symbol#name` materialization.
62
183
 
63
- **The honest trade-off, and Ruby's one real loss:** the civil date-time door is *slower*
64
- than `strptime` — 1.30 µs against `DateTime.strptime`'s 1.02 µs, and the date door 1.04 µs
65
- against `Date.strptime`'s 619 ns. The parse isn't the problem; the carrier is. Building a
66
- stdlib `DateTime` with an exact `Rational` second costs more than the whole native call,
67
- where the timestamp door's `Time` is built by a single cheap `rb_time_nano_new`. Printed
68
- because it's real: if you want Ruby's fastest civil parse and don't need the verdict or the
69
- declared order, `strptime` wins. Also note the carrier's other caveat — `DateTime`'s offset
70
- defaults to `+00:00`, which is an artifact of the type, not a zone the parse assigned.
184
+ **The honest trade-off:** the civil doors do not beat `strptime`. The date-time door is
185
+ level with `DateTime.strptime` (904 ns against 901 ns) and the date door a little behind
186
+ `Date.strptime` (620 ns against 513 ns). The parse isn't the cost; the carrier is. Building
187
+ a stdlib `DateTime` with an exact `Rational` second costs more than the whole native call,
188
+ where the timestamp door's `Time` is built by a single cheap `rb_time_nano_new`. If you want
189
+ Ruby's fastest civil parse and don't need the verdict or the declared order, `strptime` is
190
+ as good. Also note the carrier's other caveat — `DateTime`'s offset defaults to `+00:00`,
191
+ which is an artifact of the type, not a zone the parse assigned.
71
192
 
72
193
  On the Fiddle fallback the doors are parity-at-best — Fiddle's
73
194
  per-call floor is the mechanism's price, kept because it's the universal zero-compile
74
- path. 0.2.0 still took ~20% off its numeric doors (`i32` 3.37 → 2.62 µs, `f64` 3.31 →
75
- 2.76 µs, same session) by not building things per call that never changed: the integer
76
- doors interpolated and interned their `:cast_*` Symbol on every call, every door went
77
- through a splat-and-resplat dispatcher, and every numeric call copied the format's 12
78
- packed bytes into scratch — each format now owns one native pointer, memoized by identity,
79
- passed straight through. (Benchmark forensics worth knowing: the doors read 4.3 µs until
195
+ path. Its doors build nothing per call that does not change between calls: each format
196
+ owns one native pointer, memoized by identity and passed straight through. (Benchmark forensics worth knowing: the doors read 4.3 µs until
80
197
  per-call `Fiddle::Pointer.malloc` finalizers were hoisted to thread-local scratch —
81
198
  receipts include their own archaeology.)
82
199
 
200
+ ## Benchmarks
201
+
202
+ `ruby benchmark/cast_benchmark.rb` pairs each door with Ruby's closest stdlib parse. Its
203
+ first line names the backend, core version and Ruby it measured, so run it again under
204
+ `HYPERCAST_PURE=1` or `HYPERCAST_WASM=1` for the other two backends. The stdlib comparisons
205
+ are in [the section above](#why-not-integer--timeiso8601--float); this is the three backends
206
+ against each other.
207
+
208
+ Measured on Ruby 4.0.7, linux-x64 (an Intel Core i9-11900H), wasmtime 48.0.1,
209
+ `benchmark-ips`, same session, all three backends:
210
+
211
+ | Door | Magnus | Fiddle | wasmtime |
212
+ |---|---:|---:|---:|
213
+ | `bool` | 112 ns | 2.35 µs | 1.75 µs |
214
+ | `i32` | 133 ns | 2.63 µs | 1.80 µs |
215
+ | `f64` | 166 ns | 2.70 µs | 1.84 µs |
216
+ | `uuid` | 223 ns | 3.47 µs | 2.65 µs |
217
+ | `timestamp` | 447 ns | 3.25 µs | 2.42 µs |
218
+ | `datetime` (`1/7/2026 3:04 PM`) | 904 ns | 3.98 µs | 3.14 µs |
219
+ | `duration` (ISO) | 632 ns | 2.91 µs | 2.10 µs |
220
+ | `i32`, a fault | 225 ns | 3.18 µs | 2.50 µs |
221
+
222
+ A lean door on the Magnus backend is little more than the native call: the extension builds
223
+ the `Success` or `Fault` it returns directly — allocated, its members stored, frozen —
224
+ rather than through `Data.new`, whose keyword handling alone cost more than the cast.
225
+
226
+ ## Backends
227
+
228
+ | `HyperCast::BACKEND` | What runs | Chosen when |
229
+ |---|---|---|
230
+ | `:native` | the core linked into a Magnus extension | a precompiled platform gem carries an extension for this Ruby's ABI — see [Install](#install) |
231
+ | `:fiddle` | `libhypercast` for this platform, `dlopen`ed through Fiddle | no extension loads; also the answer when nothing loads at all |
232
+ | `:wasm` | the core as a `wasm32-wasip1` module inside the `wasmtime` gem | no extension and no library for this platform, and `wasmtime` is installed — see [WebAssembly (wasmtime)](#webassembly-wasmtime) |
233
+
234
+ Selection happens once, at `require`, in that order. Two environment variables override it:
235
+
236
+ | Variable | Effect |
237
+ |---|---|
238
+ | `HYPERCAST_WASM` | forces `:wasm`; `require` raises a `LoadError` naming the gem if `wasmtime` is missing |
239
+ | `HYPERCAST_PURE` | forces `:fiddle`, even where an extension would load |
240
+
241
+ Both are read for presence, not value — set to anything at all, `0` and the empty string
242
+ included, they force their backend — and `HYPERCAST_WASM` wins when both are set. A forced
243
+ backend that turns out to have nothing to load does not fall through to another one: the
244
+ first call raises, and `HyperCast.available?` answers `false`.
245
+
246
+ **Threads.** Every backend is safe to call from any number of threads. The extension runs
247
+ under the GVL. Fiddle releases the GVL for the duration of each call, so that is the one
248
+ backend where Ruby threads run the core truly in parallel — each through its own scratch
249
+ buffers; the core itself keeps no state between calls. The wasm backend serializes every call
250
+ on one Mutex around one instance. `spec/cast_spec.rb`'s concurrent-callers example runs under
251
+ all three.
252
+
253
+ **Ractors.** Main Ractor only, on every backend: called from another Ractor the doors raise
254
+ `Ractor::UnsafeError` (`Ractor::IsolationError` under wasm).
255
+
256
+ ## WebAssembly (wasmtime)
257
+
258
+ The Rust core also ships inside this gem as a `wasm32-wasip1` module
259
+ (`lib/hypercast/native/wasm32-wasip1/hypercast.wasm`), and the
260
+ [`wasmtime`](https://rubygems.org/gems/wasmtime) gem can run it in-process. This is the
261
+ inverse of ruby.wasm — not Ruby inside a wasm sandbox, but a wasm module inside Ruby — and
262
+ it is the one backend that needs no shared library for the platform it runs on: no
263
+ `dlopen`, no Magnus extension, nothing compiled against this Ruby's ABI. The Magnus
264
+ extension replaces every public door in place; this backend replaces only the four private
265
+ bodies underneath them (`plain`, `numeric`, `declared`, `packed_version` — the whole native
266
+ crossing), so every door, both verdict `Data` types, the Symbol tables and the
267
+ `Time`/`DateTime`/`Rational`/`Decimal` carriers are the exact code the other two backends
268
+ run. `spec/wasm_backend_spec.rb` pins that the outputs agree with the Fiddle backend byte for
269
+ byte.
270
+
271
+ `wasmtime` is deliberately **not** a dependency of this gem; a consumer who wants this path
272
+ installs it:
273
+
274
+ ```sh
275
+ gem install wasmtime
276
+ HYPERCAST_WASM=1 ruby -rhypercast -e 'p HyperCast::BACKEND' # => :wasm
277
+ ```
278
+
279
+ `HYPERCAST_WASM=1` forces the backend (and raises a `LoadError` naming the gem if it is
280
+ missing). Without it, the wasm backend is only ever chosen automatically when there is no
281
+ native library for this platform at all — no Magnus extension and no `libhypercast` for the
282
+ RID — and `wasmtime` happens to be installed. No supported platform's behavior changes just
283
+ because this backend exists.
284
+
285
+ Two things are different under the sandbox, both by necessity. A wasm guest only sees its own
286
+ linear memory, so the input is copied into a grow-only guest buffer and the out-value, fault
287
+ span and `NumFormat` live in guest allocations made once at load — all from the module's own
288
+ exported `malloc` (the same wasi-libc allocator Rust's std uses on that target), read back
289
+ with `Memory#read`; using the guest's allocator rather than a host-picked offset is what keeps
290
+ a buffer from being clobbered by the guest's next allocation. And a `Wasmtime::Store` is
291
+ single-threaded, so every call is serialized under one Mutex around one shared instance.
292
+
293
+ Measured against the other two backends in the same session — the `wasmtime` column of the
294
+ table under [Benchmarks](#benchmarks).
295
+
296
+ The finding worth stating: in Ruby the wasm backend is no slower than Fiddle, and on the
297
+ x64 box that table comes from it is the faster of the two on every door, by a fifth to a
298
+ third. Fiddle's per-call floor is interpreted marshalling; wasmtime's is the host-to-guest
299
+ crossing plus the guest memory copies; on the lean doors wasmtime sits eleven to sixteen
300
+ times behind the Magnus extension and Fiddle sixteen to twenty-one, narrowing to three to
301
+ five times where building the Ruby value dominates. So on a platform with no native build, the fallback costs a consumer nothing
302
+ they were not already paying on the universal gem.
303
+
83
304
  ## Verifying provenance
84
305
 
85
- Every gem RubyGems.org serves — the universal fallback and each of the six precompiled
306
+ Every gem RubyGems.org serves — the universal fallback and each of the five precompiled
86
307
  platform gems — carries its own GitHub build-provenance attestation, signed directly by
87
308
  this repo's own `release.yml` (the `rubygems-publish` job attests `ruby/pkg/*.gem` right
88
309
  before the push), so plain `--repo` verifies any of them:
@@ -93,10 +314,11 @@ gh attestation verify hypercast-X.Y.Z-<platform>.gem --repo SkunkWerkx/HyperCast
93
314
  ```
94
315
 
95
316
  That's the release's second layer of checking, not the only one: before any gem gets built,
96
- the same job verifies every native binary it packs (six FFI libs, twelve Magnus extensions —
97
- two ABIs per platform) against *their own* attestations — those are signed from
98
- `SkunkWerkx/.github` by `hyper-build-native.yml`, so that check needs
99
- `--signer-repo SkunkWerkx/.github` added — and refuses to proceed on an unverified one.
317
+ the same job verifies every native binary it packs — the Fiddle libraries, the Magnus
318
+ extensions (one per Ruby ABI per platform) and the wasm module — against *their own*
319
+ attestations — those are signed from `SkunkWerkx/.github` by `hyper-build-native.yml`, so
320
+ that check needs `--signer-repo SkunkWerkx/.github` added — and refuses to proceed on an
321
+ unverified one.
100
322
  RubyGems.org has no unpublish and no duplicate-version overwrite, so this all happens while a
101
323
  bad artifact is still reversible. The release run's job summary then re-fetches every gem
102
324
  from the CDN and records attested-vs-served digests, turning "rubygems.org stores an upload
@@ -110,10 +332,15 @@ more on why `--signer-repo` is needed for some artifacts here and not others.
110
332
  gem install hypercast
111
333
  ```
112
334
 
113
- Seven gems are published per release: one universal `ruby`-platform gem (pure Fiddle, all
114
- six platforms' natives bundled) plus six precompiled Magnus platform gems (`x86_64-linux`,
115
- `aarch64-linux`, `x86_64-darwin`, `arm64-darwin`, `x64-mingw-ucrt`, `aarch64-mingw-ucrt`)
116
- that `gem install` auto-selects when they match. Nobody ever compiles anything.
335
+ Six gems are published per release: one universal `ruby`-platform gem (Fiddle, with every
336
+ platform's native library and the wasm module bundled) plus five precompiled Magnus platform
337
+ gems (`x86_64-linux`, `aarch64-linux`, `arm64-darwin`, `x64-mingw-ucrt`,
338
+ `aarch64-mingw-ucrt`) that `gem install` and `bundle` auto-select when they match. There is
339
+ no `x86_64-darwin` platform gem: an Intel Mac installs the universal gem and runs on Fiddle
340
+ over the bundled `osx-x64` library, as Alpine does. A platform
341
+ gem carries the same libraries and module beside its extensions, so the Fiddle and wasm
342
+ backends are still there behind `HYPERCAST_PURE` and `HYPERCAST_WASM`. No extra configuration
343
+ needed either way.
117
344
 
118
345
  Selection has **two** axes here, unlike every other binding in this repo. A Magnus extension
119
346
  is bound to one Ruby minor ABI — there is no `abi3` equivalent to collapse the version axis
@@ -121,11 +348,19 @@ the way [the Python binding's](../python/) wheels do — so each platform gem is
121
348
  carrying one compiled extension per supported Ruby, under `lib/hypercast/<minor>/`, and picks
122
349
  one at `require` time:
123
350
 
124
- | Ruby | the six platforms above | anywhere else (musl/Alpine, …) |
125
- | --- | --- | --- |
126
- | 4.0 (primary) | Magnus, `backend: :native` | Fiddle |
127
- | 3.4 (floor, until its EOL 2028-03-31) | Magnus, `backend: :native` | Fiddle |
128
- | 3.2 / 3.3 | Fiddle | Fiddle |
351
+ | Ruby | glibc Linux, macOS, Windows — x64 and arm64 | musl Linux (Alpine) — x64 and arm64 | anywhere else |
352
+ | --- | --- | --- | --- |
353
+ | 4.0 (primary) | Magnus, `BACKEND == :native` | Fiddle | wasm, if `wasmtime` is installed |
354
+ | 3.4 (until its EOL 2028-03-31) | Magnus, `BACKEND == :native` | Fiddle | wasm, if `wasmtime` is installed |
355
+ | 3.3 (the floor) | Fiddle | Fiddle | wasm, if `wasmtime` is installed |
356
+
357
+ What stands behind each cell: CI replays the whole suite, shared corpus included, for the
358
+ first column on every push — Magnus on Ruby 3.4 and 4.0, Fiddle and wasm on 4.0, on all five
359
+ platforms with a CI leg (Intel macOS has none) — and the Fiddle suite for the musl column inside an Alpine container. The 3.3 row
360
+ is the same Fiddle code path, run with the Docker command under [Development](#development)
361
+ (Ruby 3.3 with the Fiddle 1.1.2 it ships) rather than on every push. The last column — wasm
362
+ chosen automatically because nothing native exists — has no leg of its own: CI runs the wasm
363
+ backend's suite forced, on platforms that have native builds too.
129
364
 
130
365
  The platform gems declare `required_ruby_version >= 3.4, < 4.1` precisely so RubyGems
131
366
  *declines* them outside that range and resolves the universal gem instead — a wrong-ABI
@@ -133,7 +368,27 @@ extension must never be installed in the first place. On Windows it would at lea
133
368
  load cleanly (the extension imports `<arch>-ucrt-ruby<minor>.dll` by name —
134
369
  `x64-ucrt-ruby400.dll` on x64, `aarch64-ucrt-ruby400.dll` on ARM), but Linux extensions don't
135
370
  link libruby at all, so one can load successfully against the wrong ABI and misbehave later.
136
- Every cell in that table replays the same corpus green.
371
+ When 3.4 goes EOL it simply leaves the matrix and its users fall back to Fiddle, which is
372
+ exactly what the fallback is for.
373
+
374
+ **musl.** RubyGems does not tell musl from glibc for these gems: on Alpine, `gem install`
375
+ picks the `x86_64-linux` or `aarch64-linux` platform gem exactly as it does on any other
376
+ Linux (checked with RubyGems 4.0.20 on `x86_64-linux-musl`). The Magnus extension inside is
377
+ linked against glibc and cannot load there — `require` fails cleanly on the missing
378
+ `ld-linux` loader — so the gem falls back to Fiddle, which loads the musl build of the core
379
+ (`native/linux-musl-x64` or `native/linux-musl-arm64`, chosen from `RUBY_PLATFORM`) that
380
+ every gem carries. Alpine therefore works as installed, on the Fiddle backend and at Fiddle's
381
+ speed. Releases through 0.3.0 carried no musl library: there the fallback found only the
382
+ glibc one, `HyperCast.available?` answered `false`, and the first cast raised
383
+ `Fiddle::DLError`.
384
+
385
+ **Nothing in this gem is ever compiled, on any platform.** Its one dependency can be:
386
+ `fiddle` is a bundled gem on Ruby 4.0 and a default gem on 3.3 and 3.4, and `gem install` is
387
+ satisfied by the copy Ruby ships. Bundler resolves the newest `fiddle` on rubygems.org
388
+ instead, and when that is newer than the one your Ruby ships — true today on 3.3 and 3.4, not
389
+ on 4.0 — it builds Fiddle's own C extension, which takes a compiler and libffi's headers
390
+ (`apk add build-base libffi-dev` on Alpine). Holding `fiddle` at your Ruby's version in the
391
+ `Gemfile.lock` avoids that.
137
392
 
138
393
  Both Windows architectures get a Magnus gem, and the reasoning that once kept them on Fiddle
139
394
  was backwards: MinGW is the *only* Windows flavour `rb-sys` targets (`x64-mingw-ucrt` and
@@ -144,7 +399,82 @@ targets rather than `gnu` — the same mingw-w64/UCRT ABI RubyInstaller's Ruby u
144
399
  with LLVM and compiler-rt instead of GCC and a statically-linked libgcc, which is what keeps
145
400
  the shipped extension small. The build script, and the two flags that are load-bearing on
146
401
  the ARM leg (a static libunwind, and a clang-spelled `--target` for bindgen), live in the
147
- forge's `hyper-build-native.yml`, shared with every other Hyper* repo.
402
+ forge's `ruby-magnus` action (`build-magnus.sh`), shared with every other Hyper* repo.
403
+
404
+ ## Development
405
+
406
+ Everything below runs from a checkout, with `rust/` and `corpus/` beside `ruby/`; none of it
407
+ is needed to use the gem. The Fiddle and wasm backends find the in-repo builds on their own
408
+ when nothing is staged under `lib/hypercast/native/`. The Magnus backend does not — an
409
+ extension has to be built for the Ruby you are running and put where `require` looks — and
410
+ that is what `rake native:dev` is for.
411
+
412
+ ```sh
413
+ cd rust
414
+ cargo build --release # libhypercast, what the Fiddle backend loads
415
+ cargo build --release --target wasm32-wasip1 # hypercast.wasm, what the wasm backend loads
416
+
417
+ cd ../ruby
418
+ bundle install
419
+ bundle exec rake native:dev # build the Magnus extension for this Ruby and stage it
420
+ bundle exec rspec # BACKEND == :native
421
+ HYPERCAST_PURE=1 bundle exec rspec # BACKEND == :fiddle
422
+ HYPERCAST_WASM=1 bundle exec rspec # BACKEND == :wasm
423
+ bundle exec rake docs:check # every public object carries a doc comment
424
+ ruby benchmark/cast_benchmark.rb # prints the backend it measured
425
+ ```
426
+
427
+ `rake native:dev` runs `cargo ruby-ext` (an alias in `rust/.cargo/config.toml` that builds the
428
+ `ruby` feature into `rust/target/ruby/`, never over the plain library in
429
+ `rust/target/release/`) and copies the result to
430
+ `lib/hypercast/<minor>/hypercast_native.<so|bundle>` — the path `lib/hypercast.rb` tries
431
+ first. The copy is also a rename, and it is the step `cargo ruby-ext` alone does not do: cargo
432
+ names its output `libhypercast.so`, and Ruby derives the `Init_` function it calls from the
433
+ file name it was asked to `require`. Three things worth knowing:
434
+
435
+ - **Run it again after changing `rust/`.** The staged file is a copy; nothing rebuilds it.
436
+ - **One staging per Ruby.** An extension is bound to one Ruby minor, so under a second Ruby
437
+ (`RBENV_VERSION=3.4.11 bundle exec rake native:dev`, say) the task rebuilds for that ABI
438
+ and stages beside the first, each Ruby loading its own.
439
+ - **Without it, `bundle exec rspec` still passes — on Fiddle.** The examples under "native
440
+ backend" report themselves pending with `BACKEND=fiddle`; that line, or
441
+ `ruby -Ilib -rhypercast -e 'p HyperCast::BACKEND'`, is how to tell which backend a run
442
+ exercised. Deleting `lib/hypercast/<minor>/` goes back to Fiddle.
443
+
444
+ The task covers Linux and macOS. The Windows extensions need the `gnullvm` targets and linker
445
+ flags in the forge's `build-magnus.sh`, which is also what CI uses on every platform.
446
+
447
+ The musl build has no host to run on outside a container. With the core built for musl at
448
+ `rust/target/musl/linux-musl-x64/libhypercast.so`, this runs the Fiddle suite — corpus replay
449
+ included — against it on Alpine, with the checkout mounted read-only:
450
+
451
+ ```sh
452
+ docker run --rm -v "$PWD/..":/src:ro ruby:4.0-alpine sh -euc '
453
+ mkdir /work && cp -r /src/ruby /work/ruby && cp -r /src/corpus /work/corpus
454
+ mkdir -p /work/ruby/lib/hypercast/native/linux-musl-x64
455
+ cp /src/rust/target/musl/linux-musl-x64/libhypercast.so /work/ruby/lib/hypercast/native/linux-musl-x64/
456
+ cd /work/ruby && rm -f Gemfile.lock
457
+ BUNDLE_WITHOUT=wasm bundle install --quiet --prefer-local
458
+ HYPERCAST_PURE=1 BUNDLE_WITHOUT=wasm bundle exec rspec'
459
+ ```
460
+
461
+ The floor's test is the same container on `ruby:3.3-alpine`. Bundler would compile a newer
462
+ Fiddle there (see [Install](#install)), so this one goes around Bundler and runs against the
463
+ Fiddle that Ruby 3.3 ships:
464
+
465
+ ```sh
466
+ docker run --rm -v "$PWD/..":/src:ro ruby:3.3-alpine sh -euc '
467
+ mkdir /work && cp -r /src/ruby /work/ruby && cp -r /src/corpus /work/corpus
468
+ mkdir -p /work/ruby/lib/hypercast/native/linux-musl-x64
469
+ cp /src/rust/target/musl/linux-musl-x64/libhypercast.so /work/ruby/lib/hypercast/native/linux-musl-x64/
470
+ cd /work/ruby && rm -f Gemfile Gemfile.lock
471
+ gem install rspec -v "~> 3.13" --no-document --silent
472
+ HYPERCAST_PURE=1 rspec'
473
+ ```
148
474
 
149
475
  See [the repo root README](../README.md) for the full door table, the receipts, and the
150
476
  state of every other language binding.
477
+
478
+ ## License
479
+
480
+ [MIT](https://github.com/SkunkWerkx/HyperCast/blob/master/LICENSE)
Binary file
Binary file
@@ -4,18 +4,24 @@ module HyperCast
4
4
  module NativePlatform
5
5
  class UnsupportedPlatformError < StandardError; end
6
6
 
7
- def self.rid_and_library_name
8
- is_arm = RUBY_PLATFORM.match?(/arm64|aarch64/)
7
+ # +platform+ is a parameter only so the specs can walk the whole table from one host;
8
+ # every real caller takes the default.
9
+ def self.rid_and_library_name(platform = RUBY_PLATFORM)
10
+ arch = platform.match?(/arm64|aarch64/) ? "arm64" : "x64"
9
11
 
10
- case RUBY_PLATFORM
12
+ case platform
11
13
  when /mingw|mswin|windows/
12
- is_arm ? ["win-arm64", "hypercast.dll"] : ["win-x64", "hypercast.dll"]
14
+ ["win-#{arch}", "hypercast.dll"]
13
15
  when /darwin/
14
- is_arm ? ["osx-arm64", "libhypercast.dylib"] : ["osx-x64", "libhypercast.dylib"]
16
+ ["osx-#{arch}", "libhypercast.dylib"]
15
17
  when /linux/
16
- is_arm ? ["linux-arm64", "libhypercast.so"] : ["linux-x64", "libhypercast.so"]
18
+ # Two C libraries, two builds: a glibc-linked library cannot be relied on to dlopen
19
+ # into a musl process (Alpine), so musl gets RIDs of its own. Ruby names the libc
20
+ # in its platform string there ("x86_64-linux-musl") and leaves it off on glibc.
21
+ os = platform.include?("musl") ? "linux-musl" : "linux"
22
+ ["#{os}-#{arch}", "libhypercast.so"]
17
23
  else
18
- raise UnsupportedPlatformError, "hypercast: unsupported platform RUBY_PLATFORM=#{RUBY_PLATFORM}"
24
+ raise UnsupportedPlatformError, "hypercast: unsupported platform RUBY_PLATFORM=#{platform}"
19
25
  end
20
26
  end
21
27
  end