schemurai 2.3.0 → 3.0.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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +67 -4
  3. data/ext/schemurai_native/Cargo.lock +1 -1
  4. data/ext/schemurai_native/Cargo.toml +1 -1
  5. data/ext/schemurai_native/glue.c +155 -14
  6. data/ext/schemurai_native/src/budget.rs +101 -0
  7. data/ext/schemurai_native/src/diagnostics.rs +395 -225
  8. data/ext/schemurai_native/src/enumeration.rs +154 -0
  9. data/ext/schemurai_native/src/evaluation.rs +219 -0
  10. data/ext/schemurai_native/src/graph.rs +50 -0
  11. data/ext/schemurai_native/src/instance_graph.rs +84 -0
  12. data/ext/schemurai_native/src/lib.rs +947 -184
  13. data/ext/schemurai_native/src/memo.rs +1149 -0
  14. data/ext/schemurai_native/src/memo_scope.rs +240 -0
  15. data/ext/schemurai_native/src/number.rs +7 -8
  16. data/ext/schemurai_native/src/pattern.rs +157 -14
  17. data/ext/schemurai_native/src/scope.rs +243 -33
  18. data/ext/schemurai_native/src/structural.rs +86 -0
  19. data/ext/schemurai_native/src/tracking.rs +245 -90
  20. data/lib/schemurai/backend.rb +1 -0
  21. data/lib/schemurai/evaluator.rb +45 -3
  22. data/lib/schemurai/limit_budget.rb +78 -0
  23. data/lib/schemurai/limit_exceeded.rb +18 -0
  24. data/lib/schemurai/limited_evaluator.rb +100 -0
  25. data/lib/schemurai/native/evaluator.rb +32 -5
  26. data/lib/schemurai/schema_domain.rb +12 -7
  27. data/lib/schemurai/schema_graph.rb +10 -1
  28. data/lib/schemurai/unique_items.rb +2 -0
  29. data/lib/schemurai/version.rb +1 -1
  30. data/lib/schemurai/vm/compiler.rb +7 -3
  31. data/lib/schemurai/vm/evaluator.rb +50 -3
  32. data/lib/schemurai/vm/limited_evaluator.rb +97 -0
  33. data/lib/schemurai.rb +74 -27
  34. data/sig/schemurai.rbs +20 -12
  35. metadata +16 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 181bce310181bba515fd0b38021092300deda955ce8ef5cefb6b9e077a51995b
4
- data.tar.gz: 506c85192a973967b9787637fb96c24d564ebf83bba7e02124f8fccf3de9de55
3
+ metadata.gz: 8fde4b5a2e6ccf979c4948fd13476c8250acf053346de28c4d62b32ed0c92e4f
4
+ data.tar.gz: 7e3a5fb2dfa17195b1a45cde045f7256a1c5f3687f19f8d2c7b821980ffe2ab1
5
5
  SHA512:
6
- metadata.gz: b6b8663cd944bd3c439892a0ebbbaea1fbce229eabe208094e89f1999cc102b60a6b04a2626caafa2dc480a18e76c4c76aca75433f2509d84ab75f1766e14e0c
7
- data.tar.gz: 4a80f08e3f35eb1edfab8d106d2967c4fdc686741b5e731f4bc7dc5c5946095a6e69272cc8257ad1d762746b78df73e932a9470a0815969c25ff54274e62631b
6
+ metadata.gz: 42c0e94ce30932d09c9f1617592338f2386076eca4d176f1e1a9461557ea723abf512f94d7c68fb52a392021e9b45cba4ab329ee3669754b6d0a996f0319a07f
7
+ data.tar.gz: 68b8c4e585cd40e11d4d7fbae8d9345f52a4bfca437704b86999eab79f219cf52e276b623faf0271cd49c4044c596f544399be77ba8a8fd36946f20524708cc2
data/README.md CHANGED
@@ -185,9 +185,12 @@ validator = registry.validator_for("https://example.test/value")
185
185
  `ResolutionError` if a reference cannot be resolved. After it returns,
186
186
  `validator_for` is read-only and may be called concurrently, while `compile` is
187
187
  no longer available. A `Validator` contains per-validation mutable state and
188
- must not be shared between threads or Ractors. With the VM backend, the registry
189
- compiles and shares its bytecode before this transition, so those validators do
190
- not compile separate instruction streams in each Ractor.
188
+ must not be shared between threads or Ractors. With the VM and native backends,
189
+ the registry compiles and shares its bytecode before this transition. The native
190
+ backend also prepares shared native instruction tables for all four combinations
191
+ of `content` and `format`; `validator_for` does not recompile them in each Ractor.
192
+ Native regex search caches and VM fallback evaluation state belong to each
193
+ validator. Preparing these tables adds time and memory to `make_shareable`.
191
194
 
192
195
  ### Meta-schema validation
193
196
 
@@ -218,10 +221,70 @@ regardless of the registry option and reuse meta-schema graph data within that
218
221
  registry. The stateful validator used for a check is temporary; no global
219
222
  validator is retained.
220
223
 
224
+ ### Opt-in validation limits (experimental)
225
+
226
+ `max_errors:` and `timeout:` are optional kwargs. Both default to `nil`
227
+ (unlimited) and apply independently to each validation call. `max_errors` must
228
+ be a nonnegative Integer; `timeout` must be finite nonnegative Integer or Float
229
+ seconds. No settings object is needed.
230
+
231
+ ```ruby
232
+ validator = Schemurai.compile(schema, max_errors: 100, timeout: 0.1)
233
+ begin
234
+ result = validator.validate(order)
235
+ rescue Schemurai::LimitExceeded => error
236
+ # Evaluation is incomplete; no partial result is returned.
237
+ warn "Validation stopped at #{error.limit}"
238
+ end
239
+
240
+ validator.validate(order, max_errors: 10) # retains the compiled timeout
241
+ validator.validate(order, timeout: nil) # retains the compiled error cap
242
+ validator.validate(order, max_errors: nil, timeout: nil) # disables both
243
+ validator.valid?(order) # applies the timeout, ignores max_errors
244
+ ```
245
+
246
+ `SchemaRegistry#compile`, `#validator_for`, the convenience `validate` / `valid?`
247
+ methods and explicit meta-schema checks also accept these kwargs. Overrides
248
+ replace only the specified option. Settings belong to each compiled validator;
249
+ a shareable registry retains no mutable invocation counters.
250
+
251
+ The error cap checks before constructing the next emitted diagnostic. Exactly
252
+ `max_errors` errors may be returned; attempting another raises `LimitExceeded`
253
+ with `limit == :max_errors`, `maximum` and `attempted` counts. Suppressed
254
+ applicator trials do not consume the cap. Boolean checks ignore the error cap
255
+ and use the ordinary evaluator when no timeout is set.
256
+
257
+ The timeout uses a monotonic elapsed-time deadline, including diagnostic
258
+ rendering and native-to-VM fallback. It checks at entry, before returning and
259
+ every 256 checkpoints in evaluation, container scans and structural traversal.
260
+ Ruby string/content operations also check before and after expensive calls.
261
+ Zero seconds stops even a trivial call. Expiry raises `LimitExceeded` with
262
+ `limit == :timeout`; `maximum` and `attempted` are configured and elapsed seconds.
263
+ Native returns an exhaustion status and releases Rust resources before Ruby
264
+ raises. A limit hit is terminal and does not trigger fallback.
265
+
266
+ This implementation never uses Ruby `Timeout`, timer threads, signals or
267
+ asynchronous exception injection. It is cooperative: one regexp search, scalar
268
+ operation, JSON decode, allocation or custom format callback may overrun until
269
+ the next checkpoint. GC and descheduling count toward elapsed time; the polling
270
+ interval bounds skipped checkpoints, not elapsed overrun. Compilation,
271
+ registration and automatic compile-time meta-schema validation are outside the
272
+ timeout. Explicit meta-schema checks can set their own timeout.
273
+
274
+ Use the error cap to bound diagnostic count and the timeout to bound prolonged
275
+ traversal. They protect different resources: a deadline does not bound the
276
+ number of diagnostics, and an error cap cannot stop a long successful check.
277
+ Input byte size and nesting depth belong at the parsing boundary. An error cap
278
+ does not bound the size of an individual message. Preempting individual scalar
279
+ operations requires operation-specific limits or a supervised worker process.
280
+ The backends retain their respective Ruby, VM and native execution paths and
281
+ native memoization. Timed native bulk pattern/format/content and Fixnum contains
282
+ scans use per-item evaluation so successful scans can poll.
283
+
221
284
  ### Backend selection
222
285
 
223
286
  `Schemurai.backend`, `SchemaRegistry#backend`, and `Validator#backend` expose
224
- the actual backend. Pass `backend: :ruby` to force the Ruby oracle or
287
+ the selected backend. Pass `backend: :ruby` to force the Ruby oracle or
225
288
  `backend: :vm` to ahead-of-time compile schemas for the validator virtual machine.
226
289
  Pass `backend: :native` to use the experimental Rust VM extension. Building the
227
290
  native extension requires a C toolchain, Ruby development headers, and Rust
@@ -82,7 +82,7 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
82
82
 
83
83
  [[package]]
84
84
  name = "schemurai-native"
85
- version = "2.3.0"
85
+ version = "3.0.0"
86
86
  dependencies = [
87
87
  "base64-simd",
88
88
  "base64-turbo",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "schemurai-native"
3
- version = "2.3.0"
3
+ version = "3.0.0"
4
4
  edition = "2024"
5
5
  rust-version = "1.93"
6
6
  publish = false
@@ -1,5 +1,6 @@
1
1
  #include <ruby.h>
2
2
  #include <ruby/encoding.h>
3
+ #include <ruby/ractor.h>
3
4
  #include <ruby/st.h>
4
5
  #include <stdint.h>
5
6
  #include <stddef.h>
@@ -7,7 +8,7 @@
7
8
  #include "protocol.h"
8
9
 
9
10
  static ID id_resolve, id_schemurai, id_resolution_error;
10
- static ID id_root, id_compile, id_graph, id_dynamic_anchor;
11
+ static ID id_root, id_compile, id_graph, id_dynamic_anchor, id_dynamic_anchor_names, id_dynamic_anchor_count;
11
12
 
12
13
  /* The C layer adapts CRuby APIs to Rust's opaque VALUEs. */
13
14
  #ifdef HAVE_RB_HASH_STLIKE_FOREACH
@@ -17,8 +18,15 @@ extern int rb_hash_stlike_foreach(VALUE, st_foreach_callback_func *, st_data_t);
17
18
  #endif
18
19
  typedef struct { void *engine; const void *roots; VALUE program; int compiling; } holder;
19
20
  extern void *sn_new(void);
20
- extern int sn_compile(void *, VALUE, VALUE, int, int, VALUE *);
21
- extern int sn_valid(const void *, VALUE);
21
+ extern int sn_compile(void *, VALUE, VALUE, int, int, int, VALUE *);
22
+ extern int sn_valid(const void *, const void *, size_t, VALUE);
23
+ extern size_t sn_node_index(const void *, VALUE);
24
+ extern size_t sn_root_index(const void *);
25
+ extern size_t sn_root_count(const void *);
26
+ extern VALUE sn_root_at(const void *, size_t);
27
+ extern void *sn_caches_new(void);
28
+ extern void sn_caches_free(void *);
29
+ extern size_t sn_caches_size(const void *);
22
30
  extern void sn_free(void *);
23
31
  extern const void *sn_roots(const void *);
24
32
  extern void sn_roots_mark(const void *);
@@ -43,7 +51,7 @@ static size_t size(const void *ptr) {
43
51
  }
44
52
  static const rb_data_type_t engine_type = {
45
53
  "Schemurai::Native::Program", {mark, release, size, NULL, {NULL}},
46
- NULL, NULL, RUBY_TYPED_FREE_IMMEDIATELY
54
+ NULL, NULL, RUBY_TYPED_FREE_IMMEDIATELY | RUBY_TYPED_FROZEN_SHAREABLE
47
55
  };
48
56
  static VALUE allocate(VALUE klass) {
49
57
  holder *h;
@@ -55,6 +63,7 @@ static VALUE allocate(VALUE klass) {
55
63
  return obj;
56
64
  }
57
65
  static VALUE initialize(VALUE self, VALUE program, VALUE compiler, VALUE format, VALUE content) {
66
+ rb_check_frozen(self);
58
67
  holder *h;
59
68
  TypedData_Get_Struct(self, holder, &engine_type, h);
60
69
  if (h->engine) rb_raise(rb_eRuntimeError, "native program is already initialized");
@@ -63,7 +72,8 @@ static VALUE initialize(VALUE self, VALUE program, VALUE compiler, VALUE format,
63
72
  h->roots = sn_roots(h->engine);
64
73
  h->compiling = 1;
65
74
  VALUE error = Qnil;
66
- int status = sn_compile(h->engine, program, compiler, RTEST(format), RTEST(content), &error);
75
+ int status = sn_compile(h->engine, program, compiler, RTEST(format), RTEST(content),
76
+ RB_TYPE_P(program, T_ARRAY), &error);
67
77
  h->compiling = 0;
68
78
  if (status < 0) {
69
79
  sn_free(h->engine);
@@ -79,8 +89,92 @@ static VALUE valid(VALUE self, VALUE instance) {
79
89
  TypedData_Get_Struct(self, holder, &engine_type, h);
80
90
  if (!h->engine) rb_raise(rb_eRuntimeError, "uninitialized native program");
81
91
  if (h->compiling) rb_raise(rb_eRuntimeError, "native program is still compiling");
82
- int status = sn_valid(h->engine, instance);
92
+ int status = sn_valid(h->engine, NULL, sn_root_index(h->engine), instance);
93
+ RB_GC_GUARD(instance);
94
+ if (status < 0) rb_raise(rb_eRuntimeError, "native evaluator panicked");
95
+ return status == SN_VERDICT_UNSUPPORTED ? Qnil : (status ? Qtrue : Qfalse);
96
+ }
97
+ /* Search caches and root selection belong to the owning validator. The Ruby
98
+ * owner keeps the shared engine and all its GC roots alive. */
99
+ typedef struct { VALUE owner; void *caches; size_t root; } runner_holder;
100
+ static void runner_mark(void *ptr) { rb_gc_mark(((runner_holder *)ptr)->owner); }
101
+ static void runner_release(void *ptr) {
102
+ runner_holder *h = ptr;
103
+ if (h->caches) sn_caches_free(h->caches);
104
+ xfree(h);
105
+ }
106
+ static size_t runner_size(const void *ptr) {
107
+ const runner_holder *h = ptr;
108
+ return sizeof(*h) + (h->caches ? sn_caches_size(h->caches) : 0);
109
+ }
110
+ static const rb_data_type_t runner_type = {
111
+ "Schemurai::Native::Runner", {runner_mark, runner_release, runner_size, NULL, {NULL}},
112
+ NULL, NULL, RUBY_TYPED_FREE_IMMEDIATELY
113
+ };
114
+ static holder *ready_program(VALUE self) {
115
+ holder *h;
116
+ TypedData_Get_Struct(self, holder, &engine_type, h);
117
+ if (!h->engine) rb_raise(rb_eRuntimeError, "uninitialized native program");
118
+ if (h->compiling) rb_raise(rb_eRuntimeError, "native program is still compiling");
119
+ return h;
120
+ }
121
+ static VALUE make_shareable(VALUE self) {
122
+ holder *h = ready_program(self);
123
+ /* TypedData's hidden references must be shareable too. Freeze temporary
124
+ * compilation arrays as well as the VM programs/graph they retain. */
125
+ size_t count = sn_root_count(h->roots);
126
+ for (size_t i = 0; i < count; i++) rb_ractor_make_shareable(sn_root_at(h->roots, i));
127
+ rb_ractor_make_shareable(h->program);
128
+ return rb_ractor_make_shareable(self);
129
+ }
130
+ static VALUE runner(VALUE self, VALUE root) {
131
+ holder *h = ready_program(self);
132
+ size_t index = sn_node_index(h->engine, root);
133
+ if (index == SIZE_MAX) rb_raise(rb_eArgError, "root was not compiled in this native program");
134
+ VALUE klass = rb_const_get(rb_obj_class(self), rb_intern_const("Runner"));
135
+ runner_holder *r;
136
+ VALUE object = TypedData_Make_Struct(klass, runner_holder, &runner_type, r);
137
+ r->owner = self;
138
+ r->root = index;
139
+ r->caches = sn_caches_new();
140
+ RB_GC_GUARD(self);
141
+ return object;
142
+ }
143
+ typedef struct { size_t max_errors; uint32_t enabled; double timeout; } native_limits;
144
+ typedef struct { uint32_t kind; size_t attempted; } native_outcome;
145
+ extern int sn_valid_with_limits(const void *, const void *, size_t, VALUE, const native_limits *, native_outcome *);
146
+ static native_limits read_limits(VALUE budget) {
147
+ native_limits limits = {0};
148
+ VALUE cap = rb_funcall(budget, rb_intern("native_max_errors"), 0);
149
+ if (!NIL_P(cap)) { limits.max_errors = NUM2SIZET(cap); limits.enabled |= 1; }
150
+ VALUE duration = rb_funcall(budget, rb_intern("native_duration"), 0);
151
+ if (!NIL_P(duration)) { limits.timeout = NUM2DBL(duration); limits.enabled |= 2; }
152
+ RB_GC_GUARD(cap);
153
+ return limits;
154
+ }
155
+ static void finish_budget(VALUE budget, const native_outcome *outcome) {
156
+ if (outcome->kind) rb_funcall(budget, rb_intern("raise_native_limit"), 2, UINT2NUM(outcome->kind), SIZET2NUM(outcome->attempted));
157
+ }
158
+ static VALUE runner_valid_with_budget(VALUE self, VALUE instance, VALUE budget) {
159
+ runner_holder *r;
160
+ TypedData_Get_Struct(self, runner_holder, &runner_type, r);
161
+ holder *h = ready_program(r->owner);
162
+ native_limits limits = read_limits(budget);
163
+ native_outcome outcome = {0};
164
+ int status = sn_valid_with_limits(h->engine, r->caches, r->root, instance, &limits, &outcome);
165
+ if (status < 0) rb_raise(rb_eRuntimeError, "native evaluator panicked");
166
+ finish_budget(budget, &outcome);
167
+ RB_GC_GUARD(instance);
168
+ RB_GC_GUARD(self);
169
+ return status == SN_VERDICT_UNSUPPORTED ? Qnil : (status ? Qtrue : Qfalse);
170
+ }
171
+ static VALUE runner_valid(VALUE self, VALUE instance) {
172
+ runner_holder *r;
173
+ TypedData_Get_Struct(self, runner_holder, &runner_type, r);
174
+ holder *h = ready_program(r->owner);
175
+ int status = sn_valid(h->engine, r->caches, r->root, instance);
83
176
  RB_GC_GUARD(instance);
177
+ RB_GC_GUARD(self);
84
178
  if (status < 0) rb_raise(rb_eRuntimeError, "native evaluator panicked");
85
179
  return status == SN_VERDICT_UNSUPPORTED ? Qnil : (status ? Qtrue : Qfalse);
86
180
  }
@@ -265,7 +359,6 @@ int sn_wide_integer_compare(VALUE value, VALUE bound) {
265
359
  if (FIXNUM_P(value)) return rb_big_sign(bound) ? -1 : 1;
266
360
  return FIX2INT(rb_big_cmp(value, bound));
267
361
  }
268
- size_t sn_integer_bytes(VALUE value) { return rb_absint_size(value, NULL); }
269
362
  int sn_integer1024(VALUE value, uint64_t *words) {
270
363
  return rb_integer_pack(value, words, 16, sizeof(uint64_t), 0,
271
364
  INTEGER_PACK_LSWORD_FIRST | INTEGER_PACK_NATIVE_BYTE_ORDER);
@@ -388,7 +481,8 @@ typedef struct {
388
481
  size_t detail;
389
482
  } diagnostic_record;
390
483
  typedef struct { void *report; } report_holder;
391
- extern void *sn_diagnose(const void *, VALUE, int *);
484
+ extern void *sn_diagnose(const void *, const void *, size_t, VALUE, int *);
485
+ extern void *sn_diagnose_with_limits(const void *, const void *, size_t, VALUE, int *, const native_limits *, native_outcome *);
392
486
  extern size_t sn_report_len(const void *);
393
487
  extern size_t sn_report_size(const void *);
394
488
  extern void sn_report_record(const void *, size_t, diagnostic_record *);
@@ -411,21 +505,25 @@ static const rb_data_type_t report_type = {
411
505
  "Schemurai::Native::Diagnostics", {report_mark, report_release, report_size, NULL, {NULL}},
412
506
  NULL, NULL, RUBY_TYPED_FREE_IMMEDIATELY
413
507
  };
414
- static VALUE diagnostics(VALUE self, VALUE instance) {
415
- holder *engine;
416
- TypedData_Get_Struct(self, holder, &engine_type, engine);
417
- if (!engine->engine) rb_raise(rb_eRuntimeError, "uninitialized native program");
418
- if (engine->compiling) rb_raise(rb_eRuntimeError, "native program is still compiling");
508
+ static VALUE collect_diagnostics(VALUE self, VALUE instance, holder *engine, const void *caches, size_t root, VALUE budget) {
419
509
  report_holder *h;
420
510
  VALUE owner = TypedData_Make_Struct(rb_cObject, report_holder, &report_type, h);
421
511
  h->report = NULL;
422
512
  int status = 0;
423
- h->report = sn_diagnose(engine->engine, instance, &status);
513
+ native_outcome outcome = {0};
514
+ if (NIL_P(budget)) {
515
+ h->report = sn_diagnose(engine->engine, caches, root, instance, &status);
516
+ } else {
517
+ native_limits limits = read_limits(budget);
518
+ h->report = sn_diagnose_with_limits(engine->engine, caches, root, instance, &status, &limits, &outcome);
519
+ }
424
520
  if (status < 0) rb_raise(rb_eRuntimeError, "native diagnostics panicked");
521
+ if (!NIL_P(budget)) finish_budget(budget, &outcome);
425
522
  if (status == SN_VERDICT_UNSUPPORTED) return Qnil;
426
523
  size_t count = sn_report_len(h->report);
427
524
  VALUE output = rb_ary_new_capa(count);
428
525
  for (size_t index = 0; index < count; index++) {
526
+ if (!NIL_P(budget) && index % 256 == 0) rb_funcall(budget, rb_intern("check_deadline!"), 0);
429
527
  diagnostic_record r;
430
528
  sn_report_record(h->report, index, &r);
431
529
  VALUE row = rb_ary_new_capa(7);
@@ -443,6 +541,22 @@ static VALUE diagnostics(VALUE self, VALUE instance) {
443
541
  RB_GC_GUARD(self);
444
542
  return output;
445
543
  }
544
+ static VALUE diagnostics(VALUE self, VALUE instance) {
545
+ holder *h = ready_program(self);
546
+ return collect_diagnostics(self, instance, h, NULL, sn_root_index(h->engine), Qnil);
547
+ }
548
+ static VALUE runner_diagnostics(VALUE self, VALUE instance) {
549
+ runner_holder *r;
550
+ TypedData_Get_Struct(self, runner_holder, &runner_type, r);
551
+ holder *h = ready_program(r->owner);
552
+ return collect_diagnostics(self, instance, h, r->caches, r->root, Qnil);
553
+ }
554
+ static VALUE runner_diagnostics_with_budget(VALUE self, VALUE instance, VALUE budget) {
555
+ runner_holder *r;
556
+ TypedData_Get_Struct(self, runner_holder, &runner_type, r);
557
+ holder *h = ready_program(r->owner);
558
+ return collect_diagnostics(self, instance, h, r->caches, r->root, budget);
559
+ }
446
560
  void Init_schemurai_native(void) {
447
561
  /* Cache fixed IDs explicitly, independent of compiler-specific macros. */
448
562
  id_resolve = rb_intern_const("resolve");
@@ -452,6 +566,8 @@ void Init_schemurai_native(void) {
452
566
  id_compile = rb_intern_const("compile");
453
567
  id_graph = rb_intern_const("@graph");
454
568
  id_dynamic_anchor = rb_intern_const("dynamic_anchor");
569
+ id_dynamic_anchor_names = rb_intern_const("dynamic_anchor_names");
570
+ id_dynamic_anchor_count = rb_intern_const("@dynamic_anchor_count");
455
571
  utf8_index = rb_utf8_encindex();
456
572
  ascii_index = rb_usascii_encindex();
457
573
  binary_index = rb_ascii8bit_encindex();
@@ -471,6 +587,14 @@ void Init_schemurai_native(void) {
471
587
  rb_define_method(klass, "initialize", initialize, 4);
472
588
  rb_define_method(klass, "valid?", valid, 1);
473
589
  rb_define_method(klass, "diagnostics", diagnostics, 1);
590
+ rb_define_method(klass, "make_shareable", make_shareable, 0);
591
+ rb_define_method(klass, "runner", runner, 1);
592
+ VALUE runner_class = rb_define_class_under(klass, "Runner", rb_cObject);
593
+ rb_undef_alloc_func(runner_class);
594
+ rb_define_method(runner_class, "valid?", runner_valid, 1);
595
+ rb_define_method(runner_class, "valid_with_budget", runner_valid_with_budget, 2);
596
+ rb_define_method(runner_class, "diagnostics", runner_diagnostics, 1);
597
+ rb_define_method(runner_class, "diagnostics_with_budget", runner_diagnostics_with_budget, 2);
474
598
  }
475
599
 
476
600
  extern int sn_object_entry(const void *, VALUE, VALUE);
@@ -623,6 +747,23 @@ static VALUE anchor_program(VALUE arg) {
623
747
  VALUE node = rb_funcall(graph, id_dynamic_anchor, 2, b->resource, b->name);
624
748
  return NIL_P(node) ? Qnil : rb_funcall(b->compiler, id_compile, 1, node);
625
749
  }
750
+ /* Anchor counts fit immediate integers: the table cannot exceed memory.
751
+ * Reading metadata invokes no Ruby method or allocation. */
752
+ size_t sn_anchor_count(VALUE resource) {
753
+ return (size_t)FIX2LONG(rb_ivar_get(resource, id_dynamic_anchor_count));
754
+ }
755
+ static VALUE anchor_names(VALUE arg) {
756
+ struct binding *b = (struct binding *)arg;
757
+ VALUE graph = rb_ivar_get(b->compiler, id_graph);
758
+ return rb_funcall(graph, id_dynamic_anchor_names, 1, b->resource);
759
+ }
760
+ int sn_anchor_names(VALUE compiler, VALUE resource, VALUE *result) {
761
+ struct binding b = {compiler, resource, Qnil};
762
+ int state = 0;
763
+ *result = rb_protect(anchor_names, (VALUE)&b, &state);
764
+ if (state) { *result = rb_errinfo(); rb_set_errinfo(Qnil); }
765
+ return state;
766
+ }
626
767
  int sn_resource_program(VALUE compiler, VALUE resource, VALUE *result) {
627
768
  struct binding b = {compiler, resource, Qnil};
628
769
  int state = 0;
@@ -0,0 +1,101 @@
1
+ //! Invocation-local native limits. Exhaustion is terminal, not VM delegation.
2
+ use std::cell::Cell;
3
+ use std::time::{Duration, Instant};
4
+
5
+ #[repr(C)]
6
+ #[derive(Clone, Copy, Default)]
7
+ pub struct Limits {
8
+ pub max_errors: usize,
9
+ pub enabled: u32,
10
+ pub timeout: f64,
11
+ }
12
+ #[repr(C)]
13
+ #[derive(Default)]
14
+ pub struct Outcome {
15
+ pub kind: u32,
16
+ pub attempted: usize,
17
+ }
18
+ #[derive(Default)]
19
+ pub(super) struct Budget {
20
+ pub limits: Limits,
21
+ pub hit: Cell<(u32, usize)>,
22
+ deadline: Option<Instant>,
23
+ checks: Cell<usize>,
24
+ }
25
+ impl Budget {
26
+ pub fn new(limits: Limits) -> Self {
27
+ Self {
28
+ limits,
29
+ deadline: if limits.enabled & 2 != 0 {
30
+ Duration::try_from_secs_f64(limits.timeout.max(0.0))
31
+ .ok()
32
+ .and_then(|duration| Instant::now().checked_add(duration))
33
+ } else {
34
+ None
35
+ },
36
+ ..Self::default()
37
+ }
38
+ }
39
+ pub fn timed(&self) -> bool {
40
+ self.limits.enabled & 2 != 0
41
+ }
42
+ pub fn check_deadline(&self) -> bool {
43
+ if self.hit.get().0 != 0 {
44
+ return false;
45
+ }
46
+ if self
47
+ .deadline
48
+ .is_some_and(|deadline| Instant::now() >= deadline)
49
+ {
50
+ self.hit.set((2, 0));
51
+ return false;
52
+ }
53
+ true
54
+ }
55
+ pub fn checkpoint(&self) -> bool {
56
+ if !self.timed() {
57
+ return true;
58
+ }
59
+ let checks = self.checks.get().wrapping_add(1);
60
+ self.checks.set(checks);
61
+ if checks & 255 == 0 {
62
+ self.check_deadline()
63
+ } else {
64
+ self.hit.get().0 == 0
65
+ }
66
+ }
67
+ }
68
+
69
+ #[cfg(test)]
70
+ mod tests {
71
+ use super::*;
72
+
73
+ #[test]
74
+ fn deadline_is_sampled_and_exhaustion_is_sticky() {
75
+ let mut budget = Budget::new(Limits {
76
+ timeout: 10.0,
77
+ enabled: 2,
78
+ ..Limits::default()
79
+ });
80
+ assert!(budget.check_deadline());
81
+ budget.deadline = Some(Instant::now() - Duration::from_secs(1));
82
+ for _ in 0..255 {
83
+ assert!(budget.checkpoint());
84
+ }
85
+ assert!(!budget.checkpoint());
86
+ assert_eq!(budget.hit.get(), (2, 0));
87
+ assert!(!budget.checkpoint());
88
+ }
89
+
90
+ #[test]
91
+ fn huge_finite_deadlines_do_not_panic() {
92
+ assert!(
93
+ Budget::new(Limits {
94
+ timeout: f64::MAX,
95
+ enabled: 2,
96
+ ..Limits::default()
97
+ })
98
+ .check_deadline()
99
+ );
100
+ }
101
+ }