yeptris 0.1.12.0 → 0.1.13.0

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: 9fa74d905a2d19dbeaf70381760c3a629738808e507d0ef9f33c9e266b756854
4
- data.tar.gz: 2d3fb1c11b0d2dbb88c5bb75e837f41abe971992c1940a8c5be191b0168411b8
3
+ metadata.gz: 76bc800fd5065540397d957ca2162d2491474a086eda5b08436f016daa8f65e4
4
+ data.tar.gz: 683259ac45cfb084722337af80c066890bdf56f2608843343ebf8951589eafa8
5
5
  SHA512:
6
- metadata.gz: e621ced9cf2707ada102a8633f7f75f597f63488f2a66da4efdb0dd3e292ef0719bbba509db321ca9e5cfd38f455a732a90cb3d1709b79f4ae7cab5ef0dd48da
7
- data.tar.gz: 9d5efd07ce2e2f498320133776e3b0ed08e1fec4977a7e5cd1b607616630377bc29f7d5708e00c058c2f7d97022a6b668d6e3865c33da17a7d9c8ba71d445f2e
6
+ metadata.gz: 3b2be1bc057ee5a059e80ac087000afd2cee325473d834db34bd0f898318634e7b9be95dce9b523f909ae6884a56c9ede549a0e7142184eddf4cc96a13a11cb9
7
+ data.tar.gz: e95683f3b475a0dca70488a43cdf23a249d2bcd49c440b4c75bf31914265c0eae9fd2992d1bf6c0ebfa5b0487b796f1a24505e42b4715840c1731ce2af845a4c
data/README.adoc CHANGED
@@ -52,9 +52,37 @@ Handles are document-scoped: `Document#free` releases everything
52
52
  (one C call), a GC finalizer backs it up, and any use after free
53
53
  raises `Yeptris::FreedError` — never a segfault.
54
54
 
55
- == Native materializer (opt-in): faster than JSON.parse
55
+ == Two load surfaces: YAML and strict JSON
56
+
57
+ `Yeptris::YAML.load` keeps the Psych contract for EVERY input —
58
+ including JSON-shaped ones. `{"a": [1,]}` is legal flow YAML (spec
59
+ production [141] allows the trailing comma); `"1e3"` is a Psych
60
+ String. These semantics never flip because input happens to look
61
+ like JSON.
62
+
63
+ `Yeptris::JSON.load` is the STRICT RFC 8259 surface — exact
64
+ `JSON.parse` semantics by construction (spec-pinned in
65
+ `spec/json_parity_spec.rb`: every value and every error case,
66
+ Bignum integers, exponent-only floats, duplicate keys). Engines,
67
+ fastest first:
68
+
69
+ 1. **Native materializer** (opt-in build; see below): a fused C scan
70
+ → `VALUE` parser. Order-alternating interleaved profile, 152 KB /
71
+ ~29k-value corpus, Ruby 3.4:
72
+ +
73
+ ....
74
+ JSON.parse min 0.670 med 0.966 mean 0.910 ms
75
+ Yeptris::JSON min 0.637 med 0.674 mean 0.680 ms
76
+ mean 0.75x faster, head-to-head 370/400 (92.5%)
77
+ ....
78
+ 2. **Record-drain fallback** (always available): the strict-JSON
79
+ validator gates, then the value records convert without the Psych
80
+ quirk table — exact parity with engine 1, spec-pinned.
56
81
 
57
- For JSON-shaped input, the binding can beat Ruby's own `JSON.parse`:
82
+ The committed profile (`benchmark/json_profile.rb`) is the fair
83
+ benchmark of record — any performance claim runs through it.
84
+
85
+ === Building the native materializer (opt-in)
58
86
 
59
87
  ....
60
88
  cd ext/yeptris_native
@@ -62,22 +90,9 @@ YEPTRIS_LIB_PATH=/path/to/libyeptris.dylib ruby extconf.rb && make
62
90
  cp native.bundle ../../lib/yeptris/ # or .so on Linux
63
91
  ....
64
92
 
65
- `require "yeptris"` picks it up automatically (a missing build falls
66
- back to the FFI Marshal ladder silently). Measured on the 152 KB /
67
- 29.4k-value JSON corpus (mean of 300, Ruby 3.4):
68
-
69
- ....
70
- JSON.parse mean 1.597 ms
71
- Yeptris::YAML.load mean 1.145 ms (0.72x — faster than JSON.parse)
72
- Psych.load mean 40.60 ms (35x slower than yeptris)
73
- ....
74
-
75
- The extension is a fused RFC 8259 → `VALUE` parser (one pass, no
76
- intermediate records): libyeptris scan kernels tokenize, the Ruby C
77
- API allocates, repeated keys/tokens share one frozen `String`, and GC
78
- is paused for the duration. YAML inputs keep the FFI ladder so
79
- timestamps, aliases, and Psych's scalar quirks resolve through the
80
- same path `Psych.load` uses.
93
+ `require "yeptris"` picks it up automatically (`Yeptris::JSON` then
94
+ uses it; missing builds silently use the fallback the gem installs
95
+ without compiling).
81
96
 
82
97
  == Shipped beyond the original plan
83
98
 
@@ -2,6 +2,9 @@
2
2
 
3
3
  require "mkmf"
4
4
 
5
+ # libyeptris location: YEPTRIS_LIB_PATH (file or dir), then sibling
6
+ # checkouts. CI sets YEPTRIS_LIB_PATH (the built shared library) and
7
+ # YEPTRIS_SRC (the C checkout) explicitly.
5
8
  lib_path = ENV["YEPTRIS_LIB_PATH"]
6
9
  candidates = []
7
10
  if lib_path
@@ -13,11 +16,12 @@ candidates << File.expand_path("../../../../yeptris/build/src", __dir__)
13
16
  candidates << File.expand_path("../../../yeptris/build-validate/src", __dir__)
14
17
  candidates << File.expand_path("../../../yeptris/build/src", __dir__)
15
18
 
16
- src_root = [
17
- File.expand_path("../../../../yeptris/src", __dir__),
18
- File.expand_path("../../../yeptris/src", __dir__),
19
- ].find { |d| d && File.directory?(File.join(d, "include")) }
20
- abort "yeptris sources not found" unless src_root
19
+ # Source root: YEPTRIS_SRC (CI / explicit), then sibling checkouts.
20
+ src_roots = [ENV["YEPTRIS_SRC"]].compact
21
+ src_roots << File.expand_path("../../../../yeptris/src", __dir__)
22
+ src_roots << File.expand_path("../../../yeptris/src", __dir__)
23
+ src_root = src_roots.find { |d| d && File.directory?(File.join(d, "include")) }
24
+ abort "yeptris sources not found (set YEPTRIS_SRC)" unless src_root
21
25
 
22
26
  $INCFLAGS << " -I#{src_root}/include -I#{src_root}/yeptris"
23
27
  %w[build-validate/generated build/generated].each do |g|
@@ -1,14 +1,14 @@
1
1
  /* json_ruby.c — fused RFC 8259 → Ruby VALUE (TODO.restructure/22). */
2
2
  #include <ruby.h>
3
3
  #include <ruby/encoding.h>
4
+ #include <ruby/intern.h>
4
5
  #include <stdlib.h>
5
6
  #include <string.h>
6
- #include "parse/numbers.h"
7
7
  #include "parse/scalars.h"
8
8
  #include "scan/json.h"
9
9
 
10
10
  #define YEP_JR_MAX 1000
11
- #define YEP_KC 256
11
+ #define YEP_KC 1024
12
12
 
13
13
  typedef struct {
14
14
  uint64_t h;
@@ -86,40 +86,37 @@ static VALUE jr_str(jr* j, int as_key) {
86
86
  sp = j->p + start + 1;
87
87
  sl = (long)(close - start - 1);
88
88
  }
89
- if (as_key || sl <= 2) return jr_cached(j, sp, sl);
89
+ if (as_key || sl <= 24) return jr_cached(j, sp, sl);
90
90
  return rb_enc_str_new(sp, sl, j->enc);
91
91
  }
92
92
 
93
93
  static VALUE jr_num(jr* j) {
94
94
  size_t start = j->i;
95
- if (!yep_json_number(j->p, j->len, &j->i)) { j->err = -2; return Qnil; }
96
- const char* s = j->p + start;
97
- uint32_t n = (uint32_t)(j->i - start);
98
- int is_float = 0, neg = 0;
99
- uint32_t k = 0;
100
- if (s[0] == '-') { neg = 1; k = 1; }
101
- for (; k < n; k++) {
102
- char c = s[k];
103
- if (c == '.' || c == 'e' || c == 'E') { is_float = 1; break; }
95
+ int shape = 0;
96
+ int64_t iv = 0;
97
+ double dv = 0.0;
98
+ /* the fused kernel (scan/json.h): ONE grammar walk, values out */
99
+ if (!yep_json_number_scan(j->p, j->len, &j->i, &shape, &iv, &dv)) {
100
+ j->err = -2;
101
+ return Qnil;
104
102
  }
105
- if (!is_float && n - (uint32_t)neg <= 18) {
106
- int64_t v = 0;
107
- for (k = (uint32_t)neg; k < n; k++) v = v * 10 + (s[k] - '0');
108
- if (neg) v = -v;
109
- return LL2NUM(v);
103
+ if (shape == 0) {
104
+ return LL2NUM(iv);
110
105
  }
111
- if (is_float) {
112
- double d = 0.0;
113
- if (yep_num_f64(s, n, &d) != 0) { j->err = -2; return Qnil; }
114
- return DBL2NUM(d);
106
+ if (shape == 1) {
107
+ return DBL2NUM(dv);
115
108
  }
116
- int64_t v = 0;
117
- if (yep_num_i64(s, n, &v) != 0) {
118
- double d = 0.0;
119
- if (yep_num_f64(s, n, &d) != 0) { j->err = -2; return Qnil; }
120
- return DBL2NUM(d);
109
+ /* integer text beyond int64: exact Bignum from the validated
110
+ * span (JSON.parse's behavior). Absurd lengths degrade to the
111
+ * approximate double. */
112
+ size_t n = j->i - start;
113
+ if (n < 512) {
114
+ char buf[512];
115
+ memcpy(buf, j->p + start, n);
116
+ buf[n] = '\0';
117
+ return rb_cstr_to_inum(buf, 10, TRUE);
121
118
  }
122
- return LL2NUM(v);
119
+ return DBL2NUM(dv);
123
120
  }
124
121
 
125
122
  static VALUE jr_object(jr* j) {
@@ -127,23 +124,44 @@ static VALUE jr_object(jr* j) {
127
124
  VALUE h = rb_hash_new_capa(8);
128
125
  jr_ws(j);
129
126
  if (j->i < j->len && j->p[j->i] == '}') { j->i++; j->depth--; return h; }
127
+ /* pairs collect into a flat buffer and land in ONE bulk insert:
128
+ * rb_hash_bulk_insert skips the per-pair method dispatch the
129
+ * aset loop pays (TODO.restructure/26's margin work) */
130
+ VALUE pairs[64];
131
+ VALUE* pv = pairs;
132
+ size_t pcap = 64, pn = 0, heap_cap = 0;
130
133
  for (;;) {
131
134
  jr_ws(j);
132
- if (j->i >= j->len || j->p[j->i] != '"') { j->err = -2; return Qnil; }
135
+ if (j->i >= j->len || j->p[j->i] != '"') { j->err = -2; goto out; }
133
136
  VALUE key = jr_str(j, 1);
134
- if (j->err) return Qnil;
137
+ if (j->err) goto out;
135
138
  jr_ws(j);
136
- if (j->i >= j->len || j->p[j->i] != ':') { j->err = -2; return Qnil; }
139
+ if (j->i >= j->len || j->p[j->i] != ':') { j->err = -2; goto out; }
137
140
  j->i++;
138
141
  VALUE val = jr_value(j);
139
- if (j->err) return Qnil;
140
- rb_hash_aset(h, key, val);
142
+ if (j->err) goto out;
143
+ if (pn + 2 > pcap) {
144
+ size_t ncap = pcap * 2;
145
+ VALUE* nv = malloc(ncap * sizeof(VALUE));
146
+ if (!nv) { j->err = -1; goto out; }
147
+ memcpy(nv, pv, pn * sizeof(VALUE));
148
+ if (pv != pairs) { free(pv); heap_cap = 1; }
149
+ pv = nv; pcap = ncap;
150
+ }
151
+ pv[pn++] = key;
152
+ pv[pn++] = val;
141
153
  jr_ws(j);
142
- if (j->i >= j->len) { j->err = -2; return Qnil; }
154
+ if (j->i >= j->len) { j->err = -2; goto out; }
143
155
  if (j->p[j->i] == ',') { j->i++; continue; }
144
- if (j->p[j->i] == '}') { j->i++; j->depth--; return h; }
145
- j->err = -2; return Qnil;
156
+ if (j->p[j->i] == '}') { j->i++; break; }
157
+ j->err = -2; goto out;
146
158
  }
159
+ rb_hash_bulk_insert((long)pn, (const VALUE*)pv, h);
160
+ out:
161
+ if (pv != pairs) { free(pv); (void)heap_cap; }
162
+ if (j->err) return Qnil;
163
+ j->depth--;
164
+ return h;
147
165
  }
148
166
 
149
167
  static VALUE jr_array(jr* j) {
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yeptris
4
+ # The STRICT JSON surface (TODO.restructure/31).
5
+ #
6
+ # `Yeptris::JSON.load` targets EXACT `JSON.parse` semantics — that
7
+ # is its parity target, spec-pinned (spec/json_parity_spec.rb).
8
+ # It is deliberately separate from Yeptris::YAML: the YAML surface
9
+ # keeps the Psych contract for every input (JSON-shaped included),
10
+ # so the two never drift into each other's semantics.
11
+ #
12
+ # Engines, fastest first (both exact — the parity spec runs against
13
+ # whichever is loaded):
14
+ # 1. the native materializer (fused C scan → VALUE; opt-in build)
15
+ # 2. the record drain + strict conversion walk (always available)
16
+ module JSON
17
+ class Error < ::Yeptris::Error; end
18
+ class ParseError < Error; end
19
+
20
+ module_function
21
+
22
+ def load(source)
23
+ source = ::Yeptris.read_input(source)
24
+ source = source.to_s
25
+ if defined?(::Yeptris::Native)
26
+ begin
27
+ return ::Yeptris::Native.load_json(source)
28
+ rescue ::Yeptris::ParseError => e
29
+ raise ParseError, e.message
30
+ end
31
+ end
32
+ strict_fallback(source)
33
+ end
34
+
35
+ # The always-available engine: the strict-JSON validator gates
36
+ # (parse_json raises on anything RFC 8259 rejects), then the
37
+ # value records convert WITHOUT the Psych quirk table — floats
38
+ # are always Floats, bools always bools ("1e3" is 1000.0 here
39
+ # and a String on the YAML surface; each surface is its own
40
+ # contract).
41
+ def strict_fallback(source)
42
+ begin
43
+ gate = ::Yeptris::Document.parse_json(source)
44
+ rescue ::Yeptris::ParseError => e
45
+ raise ParseError, e.message
46
+ end
47
+ begin
48
+ cols = ::Yeptris::FFI::ValueColumns.new
49
+ st = ::Yeptris::FFI.yeptris_value_drain_columns(
50
+ source, source.bytesize, ::Yeptris::FFI::SCHEMA_12_CORE, cols
51
+ )
52
+ raise ParseError, ::Yeptris::FFI.last_error_message if st != ::Yeptris::FFI::OK
53
+
54
+ begin
55
+ walk_strict(cols)
56
+ ensure
57
+ ::Yeptris::FFI.yeptris_value_free_columns(cols)
58
+ end
59
+ ensure
60
+ gate.free
61
+ end
62
+ end
63
+
64
+ # Placement mechanics mirror ValueML.walk_columns; the CONVERSION
65
+ # is the strict-JSON one (no ':sym' scan, no y/n quirk, no
66
+ # dot-required floats). Anchors/aliases/timestamps cannot occur
67
+ # in strict JSON — reaching them is an internal error.
68
+ def walk_strict(cols)
69
+ n = cols[:count]
70
+ kinds = cols[:kinds].read_bytes(n).unpack("C*")
71
+ ikeys = cols[:is_keys].read_bytes(n).unpack("C*")
72
+ bools = cols[:bools].read_bytes(n).unpack("C*")
73
+ offs = cols[:offs].read_bytes(n * 4).unpack("V*")
74
+ lens = cols[:lens].read_bytes(n * 4).unpack("V*")
75
+ pays = cols[:payloads].read_bytes(n * 8).unpack("q<*")
76
+ arena = cols[:arena_len].zero? ? +"" : cols[:arena].read_bytes(cols[:arena_len])
77
+ arena.force_encoding(Encoding::UTF_8)
78
+
79
+ docs = []
80
+ stack = []
81
+ pending_key = nil
82
+ i = 0
83
+ while i < n
84
+ case kinds[i]
85
+ when ValueML::DOC
86
+ docs.push(nil)
87
+ when ValueML::SEQ_OPEN
88
+ place(docs, stack, pending_key) { [] }
89
+ pending_key = nil
90
+ when ValueML::MAP_OPEN
91
+ place(docs, stack, pending_key) { {} }
92
+ pending_key = nil
93
+ when ValueML::CLOSE
94
+ stack.pop
95
+ when ValueML::V_STR
96
+ text = arena.byteslice(offs[i], lens[i])
97
+ if ikeys[i] == 1 && !stack.empty? && stack.last.is_a?(Hash)
98
+ pending_key = text
99
+ else
100
+ # Records carry int64 payloads: an integer-beyond-int64
101
+ # degrades to a PLAIN string (b==1). In strict JSON a
102
+ # plain (unquoted) scalar can ONLY be a number — every
103
+ # real string is quoted and arrives b==0 — so rebuild the
104
+ # exact Integer (JSON.parse parity, Bignum included).
105
+ if bools[i] == 1
106
+ place(docs, stack, pending_key) { Integer(text, 10) }
107
+ else
108
+ place(docs, stack, pending_key) { text }
109
+ end
110
+ pending_key = nil
111
+ end
112
+ when ValueML::V_INT
113
+ place(docs, stack, pending_key) { pays[i] }
114
+ pending_key = nil
115
+ when ValueML::V_FLOAT
116
+ place(docs, stack, pending_key) { [pays[i]].pack("q<").unpack1("E") }
117
+ pending_key = nil
118
+ when ValueML::V_BOOL
119
+ place(docs, stack, pending_key) { bools[i] == 1 }
120
+ pending_key = nil
121
+ when ValueML::V_NULL
122
+ place(docs, stack, pending_key) { nil }
123
+ pending_key = nil
124
+ else
125
+ raise Error, "internal: impossible record #{kinds[i]} in strict JSON"
126
+ end
127
+ i += 1
128
+ end
129
+ docs.empty? ? nil : docs.first
130
+ end
131
+
132
+ def place(docs, stack, key)
133
+ v = yield
134
+ if stack.empty?
135
+ docs[-1] = v
136
+ elsif key
137
+ stack.last[key] = v
138
+ else
139
+ stack.last.push(v)
140
+ end
141
+ stack.push(v) if v.is_a?(Array) || v.is_a?(Hash)
142
+ v
143
+ end
144
+ end
145
+ end
data/lib/yeptris/yaml.rb CHANGED
@@ -10,16 +10,14 @@ module Yeptris
10
10
  # Loads the FIRST document of a YAML stream as native Ruby objects.
11
11
  # schema: :compat_11 selects Psych/libyaml implicit typing
12
12
  # (yes/no, 0o/octal, sexagesimal); :core_12 (default) is YAML 1.2.
13
+ #
14
+ # This surface keeps the Psych contract for EVERY input — including
15
+ # JSON-shaped ones (`{"a": [1,]}` is legal flow YAML; `"1e3"` is a
16
+ # Psych String). Strict RFC 8259 semantics live on Yeptris::JSON
17
+ # (TODO.restructure/31): defaults follow proof, not benchmarks.
13
18
  def load(yaml, schema: :compat_11)
14
19
  yaml = Yeptris.read_input(yaml)
15
20
  yaml = yaml.to_s
16
- # The native C materializer fuses strict-JSON scan→VALUE in one
17
- # pass (beats JSON.parse on the 152 KB corpus). YAML inputs keep
18
- # the FFI ladder so timestamps, aliases, and Psych's scalar
19
- # quirks all resolve through the same path Psych.load uses.
20
- if defined?(Yeptris::Native) && native_json?(yaml)
21
- return Yeptris::Native.load_json(yaml)
22
- end
23
21
  docs = _drain_all(yaml, schema)
24
22
  docs.empty? ? nil : docs.first
25
23
  end
@@ -31,22 +29,6 @@ module Yeptris
31
29
  _drain_all(yaml, schema)
32
30
  end
33
31
 
34
- # Strict-JSON sniff: first non-space is { or [ — the fused native
35
- # path beats JSON.parse on this shape (TODO.restructure/22).
36
- def native_json?(bytes)
37
- i = 0
38
- len = bytes.bytesize
39
- while i < len
40
- c = bytes.getbyte(i)
41
- return true if c == 0x7b || c == 0x5b # { or [
42
- return false unless c == 0x20 || c == 0x09 || c == 0x0a || c == 0x0d
43
-
44
- i += 1
45
- end
46
- false
47
- end
48
- private_class_method :native_json?
49
-
50
32
  # The Marshal fast path when the loaded libyeptris has it (>= 0.1.11
51
33
  # era builds), falling back to the columnar drain and finally the
52
34
  # record drain — one code path, the fastest the library offers.
data/lib/yeptris.rb CHANGED
@@ -3,7 +3,7 @@
3
3
  module Yeptris
4
4
  # The gem's version lives in the parent namespace's file — the last
5
5
  # internal require (yeptris/version) retired with it.
6
- VERSION = "0.1.12.0".freeze
6
+ VERSION = "0.1.13.0".freeze
7
7
  # The error hierarchy lives in THIS file (the parent namespace's
8
8
  # own file): nested constants do not trigger a parent-constant
9
9
  # autoload, and the law forbids internal requires — defining the
@@ -39,6 +39,7 @@ module Yeptris
39
39
  autoload :Document, "yeptris/document"
40
40
  autoload :Node, "yeptris/node"
41
41
  autoload :YAML, "yeptris/yaml"
42
+ autoload :JSON, "yeptris/json"
42
43
  autoload :Materializer, "yeptris/materializer"
43
44
  autoload :ValueML, "yeptris/valueml"
44
45
  autoload :Psych, "yeptris/psych"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yeptris
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.12.0
4
+ version: 0.1.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -40,6 +40,7 @@ files:
40
40
  - lib/yeptris.rb
41
41
  - lib/yeptris/document.rb
42
42
  - lib/yeptris/ffi.rb
43
+ - lib/yeptris/json.rb
43
44
  - lib/yeptris/materializer.rb
44
45
  - lib/yeptris/node.rb
45
46
  - lib/yeptris/psych.rb