schemurai 2.3.0 → 3.0.1

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +67 -4
  3. data/ext/schemurai_native/Cargo.lock +8 -1
  4. data/ext/schemurai_native/Cargo.toml +2 -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/content.rs +18 -0
  8. data/ext/schemurai_native/src/diagnostics.rs +395 -225
  9. data/ext/schemurai_native/src/enumeration.rs +154 -0
  10. data/ext/schemurai_native/src/evaluation.rs +219 -0
  11. data/ext/schemurai_native/src/format.rs +118 -8
  12. data/ext/schemurai_native/src/graph.rs +50 -0
  13. data/ext/schemurai_native/src/instance_graph.rs +84 -0
  14. data/ext/schemurai_native/src/integer_scan.rs +132 -0
  15. data/ext/schemurai_native/src/lib.rs +954 -185
  16. data/ext/schemurai_native/src/memo.rs +1149 -0
  17. data/ext/schemurai_native/src/memo_scope.rs +240 -0
  18. data/ext/schemurai_native/src/number.rs +7 -8
  19. data/ext/schemurai_native/src/pattern.rs +191 -22
  20. data/ext/schemurai_native/src/scope.rs +243 -33
  21. data/ext/schemurai_native/src/structural.rs +86 -0
  22. data/ext/schemurai_native/src/tracking.rs +245 -90
  23. data/lib/schemurai/backend.rb +1 -0
  24. data/lib/schemurai/evaluator.rb +45 -3
  25. data/lib/schemurai/limit_budget.rb +78 -0
  26. data/lib/schemurai/limit_exceeded.rb +18 -0
  27. data/lib/schemurai/limited_evaluator.rb +100 -0
  28. data/lib/schemurai/native/evaluator.rb +32 -5
  29. data/lib/schemurai/schema_domain.rb +12 -7
  30. data/lib/schemurai/schema_graph.rb +10 -1
  31. data/lib/schemurai/unique_items.rb +2 -0
  32. data/lib/schemurai/version.rb +1 -1
  33. data/lib/schemurai/vm/compiler.rb +7 -3
  34. data/lib/schemurai/vm/evaluator.rb +50 -3
  35. data/lib/schemurai/vm/limited_evaluator.rb +97 -0
  36. data/lib/schemurai.rb +74 -27
  37. data/sig/schemurai.rbs +20 -12
  38. metadata +17 -4
@@ -17,9 +17,25 @@ pub struct Report {
17
17
  errors: Vec<Error>,
18
18
  instance_path: String,
19
19
  schema_path: String,
20
+ // Explicit failure signal for the current evaluation, independent of the
21
+ // number or rendering of diagnostic records. Child evaluations isolate it.
22
+ failed: bool,
23
+ limits: budget::Limits,
24
+ limit_hit: (u32, usize),
20
25
  }
21
26
  impl Report {
22
- fn add(&mut self, keyword: &'static str, source: (i32, Value), instance: Value, detail: usize) {
27
+ fn add(
28
+ &mut self,
29
+ keyword: &'static str,
30
+ source: (i32, Value),
31
+ instance: Value,
32
+ detail: usize,
33
+ ) -> Result<(), Verdict> {
34
+ if self.limits.enabled & 1 != 0 && self.errors.len() >= self.limits.max_errors {
35
+ self.limit_hit = (1, self.errors.len().saturating_add(1));
36
+ return Err(Unsupported);
37
+ }
38
+ self.failed = true;
23
39
  let schema_path = if keyword == "falseSchema" {
24
40
  self.schema_path.clone()
25
41
  } else {
@@ -34,13 +50,14 @@ impl Report {
34
50
  instance,
35
51
  detail,
36
52
  });
53
+ Ok(())
37
54
  }
38
- fn scoped(
55
+ fn scoped<T>(
39
56
  &mut self,
40
57
  segment: &str,
41
58
  instance: Option<usize>,
42
- visit: impl FnOnce(&mut Self) -> Result<(), Verdict>,
43
- ) -> Result<(), Verdict> {
59
+ visit: impl FnOnce(&mut Self) -> Result<T, Verdict>,
60
+ ) -> Result<T, Verdict> {
44
61
  use std::fmt::Write;
45
62
  let schema_len = self.schema_path.len();
46
63
  let instance_len = self.instance_path.len();
@@ -54,13 +71,13 @@ impl Report {
54
71
  self.instance_path.truncate(instance_len);
55
72
  result
56
73
  }
57
- fn property(
74
+ fn property<T>(
58
75
  &mut self,
59
76
  segment: &str,
60
77
  key: Value,
61
78
  schema_key: Option<Value>,
62
- visit: impl FnOnce(&mut Self) -> Result<(), Verdict>,
63
- ) -> Result<(), Verdict> {
79
+ visit: impl FnOnce(&mut Self) -> Result<T, Verdict>,
80
+ ) -> Result<T, Verdict> {
64
81
  let name = pointer_name(key)?;
65
82
  let segment = if let Some(key) = schema_key {
66
83
  format!("{segment}/{}", pointer_name(key)?)
@@ -91,22 +108,75 @@ fn pointer_name(value: Value) -> Result<String, Verdict> {
91
108
  Ok(name.replace('~', "~0").replace('/', "~1"))
92
109
  }
93
110
 
94
- impl Engine {
95
- fn collect(
111
+ impl Execution<'_> {
112
+ fn collect<const ANNOTATIONS: bool>(
96
113
  &self,
97
114
  report: &mut Report,
98
115
  node: usize,
99
116
  value: Value,
100
117
  depth: usize,
101
118
  active: scope::Context<'_>,
102
- ) -> Result<(), Verdict> {
119
+ ) -> Result<Tracked, Verdict> {
103
120
  if depth > MAX_EVALUATION_DEPTH {
104
121
  return Err(Unsupported);
105
122
  }
123
+ if !self.budget.checkpoint() {
124
+ return Err(Unsupported);
125
+ }
106
126
  let kind = unsafe { sn_kind(value) };
107
127
  if kind == TYPE_UNSUPPORTED {
108
128
  return Err(Unsupported);
109
129
  }
130
+ self.collect_kind::<ANNOTATIONS>(report, node, value, depth, active, kind)
131
+ }
132
+ fn collect_kind<const ANNOTATIONS: bool>(
133
+ &self,
134
+ report: &mut Report,
135
+ node: usize,
136
+ value: Value,
137
+ depth: usize,
138
+ active: scope::Context<'_>,
139
+ kind: i32,
140
+ ) -> Result<Tracked, Verdict> {
141
+ // A diagnostic success is established by complete traversal, never by
142
+ // a short-circuit validity result. Keep exported annotations and the
143
+ // collection policy separate; failures retain their path-specific errors.
144
+ if !self.budget.checkpoint() {
145
+ return Err(Unsupported);
146
+ }
147
+ let key = self
148
+ .memo
149
+ .key(self, node, value, depth, active, &self.budget);
150
+ if self.budget.hit.get().0 != 0 {
151
+ return Err(Unsupported);
152
+ }
153
+ if let Some(key) = key
154
+ && let Some(result) = self.memo.diagnostic_success(key, ANNOTATIONS)
155
+ {
156
+ return Ok(result);
157
+ }
158
+ let result = self.collect_uncached::<ANNOTATIONS>(report, node, value, depth, active, kind);
159
+ if let Some(key) = key
160
+ && let Ok(coverage) = &result
161
+ && coverage.status == Valid
162
+ {
163
+ self.memo
164
+ .record_diagnostic_success(key, ANNOTATIONS, coverage);
165
+ }
166
+ result
167
+ }
168
+ fn collect_uncached<const ANNOTATIONS: bool>(
169
+ &self,
170
+ report: &mut Report,
171
+ node: usize,
172
+ value: Value,
173
+ depth: usize,
174
+ active: scope::Context<'_>,
175
+ kind: i32,
176
+ ) -> Result<Tracked, Verdict> {
177
+ if depth > MAX_EVALUATION_DEPTH {
178
+ return Err(Unsupported);
179
+ }
110
180
  let frame = scope::ScopeFrame {
111
181
  resource: self.metadata[node].resource,
112
182
  parent: active.scope,
@@ -116,19 +186,23 @@ impl Engine {
116
186
  .or(self.metadata[node].primary_binding),
117
187
  };
118
188
  let active = self.enter_scope(node, active, &frame);
119
- let tracked = self.tracks[node];
189
+ let tracked = ANNOTATIONS || self.tracks[node];
190
+ let parent_failed = std::mem::replace(&mut report.failed, false);
120
191
  let mut prior = Tracked::new(Valid);
121
192
  for (op, &source) in self.nodes[node].iter().zip(&self.error_operands[node]) {
193
+ if !self.budget.checkpoint() {
194
+ return Err(Unsupported);
195
+ }
122
196
  match op {
123
197
  Op::Unsupported => return Err(Unsupported),
124
198
  Op::Boolean(false) => {
125
- report.add("falseSchema", source, value, 0);
199
+ report.add("falseSchema", source, value, 0)?;
126
200
  prior = Tracked::new(Invalid);
127
201
  }
128
202
  Op::Boolean(true) => {}
129
203
  Op::Type(mask) => {
130
204
  if kind & mask == 0 {
131
- report.add("type", source, value, 0);
205
+ report.add("type", source, value, 0)?;
132
206
  }
133
207
  }
134
208
  Op::Const(..) | Op::Enum(..) => match self.leaf(op, kind, value, depth) {
@@ -142,12 +216,12 @@ impl Engine {
142
216
  source,
143
217
  value,
144
218
  0,
145
- ),
219
+ )?,
146
220
  Valid => {}
147
221
  },
148
222
  Op::Number(required, rules) => {
149
223
  if *required != 0 && kind & required == 0 {
150
- report.add("type", source, value, 0);
224
+ report.add("type", source, value, 0)?;
151
225
  }
152
226
  if kind & TYPE_NUMBER != 0 {
153
227
  let bits = rules.failures(value)?;
@@ -158,15 +232,18 @@ impl Engine {
158
232
  (EXCLUSIVE_MINIMUM, "exclusiveMinimum"),
159
233
  (MULTIPLE_OF, "multipleOf"),
160
234
  ] {
235
+ if !self.budget.checkpoint() {
236
+ return Err(Unsupported);
237
+ }
161
238
  if bits & bit != 0 {
162
- report.add(keyword, source, value, 0);
239
+ report.add(keyword, source, value, 0)?;
163
240
  }
164
241
  }
165
242
  }
166
243
  }
167
244
  Op::String(required, rules) => {
168
245
  if *required != 0 && kind != TYPE_STRING {
169
- report.add("type", source, value, 0);
246
+ report.add("type", source, value, 0)?;
170
247
  }
171
248
  if kind == TYPE_STRING {
172
249
  let len = if rules.min.is_some() || rules.max.is_some() {
@@ -175,22 +252,22 @@ impl Engine {
175
252
  0
176
253
  };
177
254
  if rules.max.is_some_and(|max| len > max) {
178
- report.add("maxLength", source, value, len);
255
+ report.add("maxLength", source, value, len)?;
179
256
  }
180
257
  if rules.min.is_some_and(|min| len < min) {
181
- report.add("minLength", source, value, len);
258
+ report.add("minLength", source, value, len)?;
182
259
  }
183
260
  if let Some(pattern) = &rules.pattern {
184
- match pattern.eval(value) {
261
+ match pattern.eval(self.caches, value) {
185
262
  Unsupported => return Err(Unsupported),
186
- Invalid => report.add("pattern", source, value, 0),
263
+ Invalid => report.add("pattern", source, value, 0)?,
187
264
  Valid => {}
188
265
  }
189
266
  }
190
267
  if let Some(format) = &rules.format {
191
268
  match format.eval(value) {
192
269
  Unsupported => return Err(Unsupported),
193
- Invalid => report.add("format", source, value, 0),
270
+ Invalid => report.add("format", source, value, 0)?,
194
271
  Valid => {}
195
272
  }
196
273
  }
@@ -206,15 +283,18 @@ impl Engine {
206
283
  source,
207
284
  value,
208
285
  0,
209
- ),
286
+ )?,
210
287
  Valid => {}
211
288
  }
212
289
  }
213
290
  }
214
291
  }
215
292
  Op::Array(required, rules) => {
293
+ if kind == TYPE_ARRAY && !self.budget.checkpoint() {
294
+ return Err(Unsupported);
295
+ }
216
296
  if *required != 0 && kind != TYPE_ARRAY {
217
- report.add("type", source, value, 0);
297
+ report.add("type", source, value, 0)?;
218
298
  }
219
299
  if kind != TYPE_ARRAY {
220
300
  continue;
@@ -222,24 +302,27 @@ impl Engine {
222
302
  let mut coverage = Tracked::new(Valid);
223
303
  let len = unsafe { sn_len(value) };
224
304
  if rules.max.is_some_and(|max| len > max) {
225
- report.add("maxItems", source, value, len);
305
+ report.add("maxItems", source, value, len)?;
226
306
  }
227
307
  if rules.min.is_some_and(|min| len < min) {
228
- report.add("minItems", source, value, len);
308
+ report.add("minItems", source, value, len)?;
229
309
  }
230
310
  if rules.unique {
231
- match unique(value, depth, self.fixnum_flag) {
311
+ match unique(value, depth, self.fixnum_flag, &self.budget) {
232
312
  Unsupported => return Err(Unsupported),
233
- Invalid => report.add("uniqueItems", source, value, 0),
313
+ Invalid => report.add("uniqueItems", source, value, 0)?,
234
314
  Valid => {}
235
315
  }
236
316
  }
237
317
  for (index, &child) in rules.prefix.iter().take(len).enumerate() {
318
+ if !self.budget.checkpoint() {
319
+ return Err(Unsupported);
320
+ }
238
321
  if tracked {
239
322
  coverage.item(index);
240
323
  }
241
324
  report.scoped(&format!("prefixItems/{index}"), Some(index), |r| {
242
- self.collect(
325
+ self.collect::<false>(
243
326
  r,
244
327
  child,
245
328
  unsafe { sn_entry(value, index) },
@@ -250,11 +333,14 @@ impl Engine {
250
333
  }
251
334
  if rules.list {
252
335
  for (index, &child) in rules.items.iter().take(len).enumerate() {
336
+ if !self.budget.checkpoint() {
337
+ return Err(Unsupported);
338
+ }
253
339
  if tracked {
254
340
  coverage.item(index);
255
341
  }
256
342
  report.scoped(&format!("items/{index}"), Some(index), |r| {
257
- self.collect(
343
+ self.collect::<false>(
258
344
  r,
259
345
  child,
260
346
  unsafe { sn_entry(value, index) },
@@ -265,11 +351,14 @@ impl Engine {
265
351
  }
266
352
  if let Some(child) = rules.additional {
267
353
  for index in rules.items.len()..len {
354
+ if !self.budget.checkpoint() {
355
+ return Err(Unsupported);
356
+ }
268
357
  if tracked {
269
358
  coverage.item(index);
270
359
  }
271
360
  report.scoped("additionalItems", Some(index), |r| {
272
- self.collect(
361
+ self.collect::<false>(
273
362
  r,
274
363
  child,
275
364
  unsafe { sn_entry(value, index) },
@@ -300,6 +389,7 @@ impl Engine {
300
389
  }
301
390
  _ => None,
302
391
  };
392
+ let pattern = pattern.filter(|_| !self.budget.timed());
303
393
  let number = match self.nodes[child].as_slice() {
304
394
  [Op::Number(required, number)] if depth < MAX_EVALUATION_DEPTH => {
305
395
  Some((*required, number))
@@ -312,81 +402,58 @@ impl Engine {
312
402
  }
313
403
  _ => None,
314
404
  };
405
+ let format = format.filter(|_| !self.budget.timed());
315
406
  let content = match self.nodes[child].as_slice() {
316
407
  [Op::String(required, string)] if depth < MAX_EVALUATION_DEPTH => {
317
408
  string.content_only().map(|content| (*required, content))
318
409
  }
319
410
  _ => None,
320
411
  };
321
- if let Some((required, pattern)) = pattern {
412
+ let content = content.filter(|_| !self.budget.timed());
413
+ if pattern.is_some() || format.is_some() || content.is_some() {
322
414
  let start = rules.prefix.len().min(len);
323
415
  if tracked {
324
416
  for index in start..len {
417
+ if !self.budget.checkpoint() {
418
+ return Err(Unsupported);
419
+ }
325
420
  coverage.item(index);
326
421
  }
327
422
  }
328
- let status = pattern.visit_invalid_items(
329
- required,
330
- &values[start..],
331
- |index, item| match report.scoped(
332
- "items",
333
- Some(start + index),
334
- |r| self.collect(r, child, item, depth + 1, active),
335
- ) {
336
- Ok(()) => Valid,
337
- Err(status) => status,
338
- },
339
- );
340
- if status != Valid {
341
- return Err(status);
342
- }
343
- } else if let Some((required, format)) = format {
344
- let start = rules.prefix.len().min(len);
345
- if tracked {
346
- for index in start..len {
347
- coverage.item(index);
423
+ // The invalid callback is shared, but each scanner retains
424
+ // its statically selected parser and guarded byte views.
425
+ let invalid = |index, item| match report.scoped(
426
+ "items",
427
+ Some(start + index),
428
+ |r| self.collect::<false>(r, child, item, depth + 1, active),
429
+ ) {
430
+ Ok(_) => Valid,
431
+ Err(status) => status,
432
+ };
433
+ let status = match (pattern, format, content) {
434
+ (Some((required, pattern)), _, _) => pattern.visit_invalid_items(
435
+ self.caches,
436
+ required,
437
+ &values[start..],
438
+ invalid,
439
+ ),
440
+ (_, Some((required, format)), _) => {
441
+ format.visit_invalid_items(required, &values[start..], invalid)
348
442
  }
349
- }
350
- let status = format.visit_invalid_items(
351
- required,
352
- &values[start..],
353
- |index, item| match report.scoped(
354
- "items",
355
- Some(start + index),
356
- |r| self.collect(r, child, item, depth + 1, active),
357
- ) {
358
- Ok(()) => Valid,
359
- Err(status) => status,
360
- },
361
- );
362
- if status != Valid {
363
- return Err(status);
364
- }
365
- } else if let Some((required, content)) = content {
366
- let start = rules.prefix.len().min(len);
367
- if tracked {
368
- for index in start..len {
369
- coverage.item(index);
443
+ (_, _, Some((required, content))) => {
444
+ content.visit_invalid_items(required, &values[start..], invalid)
370
445
  }
371
- }
372
- let status = content.visit_invalid_items(
373
- required,
374
- &values[start..],
375
- |index, item| match report.scoped(
376
- "items",
377
- Some(start + index),
378
- |r| self.collect(r, child, item, depth + 1, active),
379
- ) {
380
- Ok(()) => Valid,
381
- Err(status) => status,
382
- },
383
- );
446
+ _ => unreachable!("a scalar item scanner was selected"),
447
+ };
384
448
  if status != Valid {
385
449
  return Err(status);
386
450
  }
387
451
  } else {
388
452
  for (index, &item) in values.iter().enumerate().skip(rules.prefix.len())
389
453
  {
454
+ if !self.budget.checkpoint() {
455
+ return Err(Unsupported);
456
+ }
390
457
  if tracked {
391
458
  coverage.item(index);
392
459
  }
@@ -417,29 +484,37 @@ impl Engine {
417
484
  continue;
418
485
  }
419
486
  report.scoped("items", Some(index), |r| {
420
- self.collect(r, child, item, depth + 1, active)
487
+ self.collect::<false>(r, child, item, depth + 1, active)
421
488
  })?;
422
489
  }
423
490
  }
424
491
  }
425
492
  if let Some(child) = rules.contains {
426
- let mut matches = 0;
427
493
  let values = if len == 0 {
428
494
  &[]
429
495
  } else {
430
496
  unsafe { std::slice::from_raw_parts(sn_array_data(value), len) }
431
497
  };
432
- for (index, &item) in values.iter().enumerate() {
433
- if !self.needs_diagnostics(child, item, depth + 1, active)? {
434
- matches += 1;
498
+ let matches = evaluation::scan_contains(
499
+ len,
500
+ |index| match self.needs_diagnostics(
501
+ child,
502
+ values[index],
503
+ depth + 1,
504
+ active,
505
+ ) {
506
+ Ok(needs) => verdict(!needs),
507
+ Err(status) => status,
508
+ },
509
+ |index| {
435
510
  if tracked {
436
511
  coverage.item(index);
437
512
  }
438
- }
439
- }
513
+ },
514
+ )?;
440
515
  if bounds(matches, Some(rules.min_contains), rules.max_contains) == Invalid
441
516
  {
442
- report.add("contains", source, value, matches);
517
+ report.add("contains", source, value, matches)?;
443
518
  }
444
519
  }
445
520
  if let Some(child) = rules
@@ -447,11 +522,14 @@ impl Engine {
447
522
  .filter(|_| prior.item_count() < len && coverage.item_count() < len)
448
523
  {
449
524
  for index in 0..len {
525
+ if !self.budget.checkpoint() {
526
+ return Err(Unsupported);
527
+ }
450
528
  if prior.contains_item(index) || coverage.contains_item(index) {
451
529
  continue;
452
530
  }
453
531
  report.scoped("unevaluatedItems", Some(index), |r| {
454
- self.collect(
532
+ self.collect::<false>(
455
533
  r,
456
534
  child,
457
535
  unsafe { sn_entry(value, index) },
@@ -467,8 +545,11 @@ impl Engine {
467
545
  }
468
546
  }
469
547
  Op::Object(required, rules) => {
548
+ if kind == TYPE_OBJECT && !self.budget.checkpoint() {
549
+ return Err(Unsupported);
550
+ }
470
551
  if *required != 0 && kind != TYPE_OBJECT {
471
- report.add("type", source, value, 0);
552
+ report.add("type", source, value, 0)?;
472
553
  }
473
554
  if kind != TYPE_OBJECT {
474
555
  continue;
@@ -476,14 +557,17 @@ impl Engine {
476
557
  let mut coverage = Tracked::new(Valid);
477
558
  let len = unsafe { sn_len(value) };
478
559
  if rules.max.is_some_and(|max| len > max) {
479
- report.add("maxProperties", source, value, len);
560
+ report.add("maxProperties", source, value, len)?;
480
561
  }
481
562
  if rules.min.is_some_and(|min| len < min) {
482
- report.add("minProperties", source, value, len);
563
+ report.add("minProperties", source, value, len)?;
483
564
  }
484
565
  for &name in &rules.required_order {
566
+ if !self.budget.checkpoint() {
567
+ return Err(Unsupported);
568
+ }
485
569
  if missing(value, name, self.undefined) {
486
- report.add("required", source, name, 0);
570
+ report.add("required", source, name, 0)?;
487
571
  }
488
572
  }
489
573
  let mut context = ObjectDiagnostics {
@@ -525,14 +609,20 @@ impl Engine {
525
609
  }
526
610
  }
527
611
  for dependency in &rules.diagnostic_dependencies {
612
+ if !self.budget.checkpoint() {
613
+ return Err(Unsupported);
614
+ }
528
615
  match dependency {
529
616
  DiagnosticDependency::Required(name, names, keyword) => {
530
617
  if missing(value, *name, self.undefined) {
531
618
  continue;
532
619
  }
533
620
  for &required in names {
621
+ if !self.budget.checkpoint() {
622
+ return Err(Unsupported);
623
+ }
534
624
  if missing(value, required, self.undefined) {
535
- report.add(keyword, (source.0, *name), required, 0);
625
+ report.add(keyword, (source.0, *name), required, 0)?;
536
626
  }
537
627
  }
538
628
  }
@@ -540,27 +630,42 @@ impl Engine {
540
630
  if missing(value, *name, self.undefined) {
541
631
  continue;
542
632
  }
543
- if self.needs_diagnostics(*child, value, depth + 1, active)? {
544
- report.scoped(
633
+ if tracked {
634
+ let result = report.scoped(
545
635
  &format!("{keyword}/{}", pointer_name(*name)?),
546
636
  None,
547
- |r| self.collect(r, *child, value, depth + 1, active),
637
+ |r| {
638
+ self.collect::<true>(
639
+ r,
640
+ *child,
641
+ value,
642
+ depth + 1,
643
+ active,
644
+ )
645
+ },
548
646
  )?;
549
- }
550
- if tracked {
551
- let result = self.eval_tracked_kind(
552
- *child,
553
- value,
554
- depth + 1,
555
- active,
556
- kind,
557
- );
558
- if result.status == Unsupported {
559
- return Err(Unsupported);
560
- }
561
647
  if result.status == Valid {
562
648
  coverage.merge(result);
563
649
  }
650
+ } else if self.needs_diagnostics(
651
+ *child,
652
+ value,
653
+ depth + 1,
654
+ active,
655
+ )? {
656
+ report.scoped(
657
+ &format!("{keyword}/{}", pointer_name(*name)?),
658
+ None,
659
+ |r| {
660
+ self.collect::<false>(
661
+ r,
662
+ *child,
663
+ value,
664
+ depth + 1,
665
+ active,
666
+ )
667
+ },
668
+ )?;
564
669
  }
565
670
  }
566
671
  }
@@ -599,17 +704,18 @@ impl Engine {
599
704
  Op::All(nodes) => {
600
705
  let mut coverage = Tracked::new(Valid);
601
706
  for (index, &child) in nodes.iter().enumerate() {
602
- report.scoped(&format!("allOf/{index}"), None, |r| {
603
- self.collect(r, child, value, depth + 1, active)
707
+ if !self.budget.checkpoint() {
708
+ return Err(Unsupported);
709
+ }
710
+ let result = report.scoped(&format!("allOf/{index}"), None, |r| {
711
+ if tracked {
712
+ self.collect_kind::<true>(r, child, value, depth + 1, active, kind)
713
+ } else {
714
+ self.collect_kind::<false>(r, child, value, depth + 1, active, kind)
715
+ }
604
716
  })?;
605
717
  if tracked {
606
- coverage.merge_diagnostics(self.eval_tracked_kind(
607
- child,
608
- value,
609
- depth + 1,
610
- active,
611
- kind,
612
- ))?;
718
+ coverage.merge_diagnostics(result)?;
613
719
  }
614
720
  }
615
721
  if tracked {
@@ -617,52 +723,56 @@ impl Engine {
617
723
  }
618
724
  }
619
725
  Op::Any(nodes) | Op::One(nodes) => {
620
- let mut matches = 0;
621
- let mut coverage = Tracked::new(Valid);
622
- for &(child, multiplicity) in nodes {
623
- let branch = if tracked {
624
- self.eval_tracked_kind(child, value, depth + 1, active, kind)
625
- } else {
626
- Tracked::new(self.eval(child, value, depth + 1, active))
627
- };
628
- match branch.status {
629
- Unsupported => return Err(Unsupported),
630
- Valid => {
631
- matches += multiplicity;
632
- if tracked {
633
- coverage.merge(branch);
634
- }
635
- }
636
- Invalid => {}
637
- }
638
- }
639
- if (matches!(op, Op::Any(_)) && matches == 0)
640
- || (matches!(op, Op::One(_)) && matches != 1)
641
- {
642
- report.add(
643
- if matches!(op, Op::Any(_)) {
644
- "anyOf"
645
- } else {
646
- "oneOf"
726
+ let branch_kind = if matches!(op, Op::Any(_)) {
727
+ evaluation::BranchKind::Any
728
+ } else {
729
+ evaluation::BranchKind::One
730
+ };
731
+ // Complete scanning preserves exact oneOf counts and VM
732
+ // exceptions from branches after an anyOf success.
733
+ let result = if tracked {
734
+ evaluation::scan_branches(
735
+ nodes,
736
+ evaluation::BranchPolicy::Complete,
737
+ |child| self.eval_tracked_kind(child, value, depth + 1, active, kind),
738
+ )?
739
+ } else {
740
+ let result = evaluation::scan_branches(
741
+ nodes,
742
+ evaluation::BranchPolicy::Complete,
743
+ |child| {
744
+ self.eval_with_kind(child, value, depth + 1, active, Some(kind))
647
745
  },
648
- source,
649
- value,
650
- matches,
651
- );
746
+ )?;
747
+ evaluation::BranchMatches {
748
+ matches: result.matches,
749
+ coverage: Tracked::new(Valid),
750
+ }
751
+ };
752
+ if !branch_kind.accepts(result.matches) {
753
+ report.add(branch_kind.keyword(), source, value, result.matches)?;
652
754
  } else if tracked {
653
- prior.merge_diagnostics(coverage)?;
755
+ prior.merge_diagnostics(result.coverage)?;
756
+ }
757
+ }
758
+ Op::Not(child) => {
759
+ match self.eval_with_kind(*child, value, depth + 1, active, Some(kind)) {
760
+ Unsupported => return Err(Unsupported),
761
+ Valid => report.add("not", source, value, 0)?,
762
+ Invalid => {}
654
763
  }
655
764
  }
656
- Op::Not(child) => match self.eval(*child, value, depth + 1, active) {
657
- Unsupported => return Err(Unsupported),
658
- Valid => report.add("not", source, value, 0),
659
- Invalid => {}
660
- },
661
765
  Op::Conditional(condition, yes, no) => {
662
766
  let condition = if tracked {
663
767
  self.eval_tracked_kind(*condition, value, depth + 1, active, kind)
664
768
  } else {
665
- Tracked::new(self.eval(*condition, value, depth + 1, active))
769
+ Tracked::new(self.eval_with_kind(
770
+ *condition,
771
+ value,
772
+ depth + 1,
773
+ active,
774
+ Some(kind),
775
+ ))
666
776
  };
667
777
  let mut coverage = Tracked::new(Valid);
668
778
  let (child, segment) = match condition.status {
@@ -676,17 +786,22 @@ impl Engine {
676
786
  Invalid => (no, "else"),
677
787
  };
678
788
  if let Some(child) = child {
679
- report.scoped(segment, None, |r| {
680
- self.collect(r, *child, value, depth + 1, active)
789
+ let result = report.scoped(segment, None, |r| {
790
+ if tracked {
791
+ self.collect_kind::<true>(r, *child, value, depth + 1, active, kind)
792
+ } else {
793
+ self.collect_kind::<false>(
794
+ r,
795
+ *child,
796
+ value,
797
+ depth + 1,
798
+ active,
799
+ kind,
800
+ )
801
+ }
681
802
  })?;
682
803
  if tracked {
683
- coverage.merge_diagnostics(self.eval_tracked_kind(
684
- *child,
685
- value,
686
- depth + 1,
687
- active,
688
- kind,
689
- ))?;
804
+ coverage.merge_diagnostics(result)?;
690
805
  }
691
806
  }
692
807
  if tracked {
@@ -707,34 +822,29 @@ impl Engine {
707
822
  OP_DYNAMIC_REF => "$dynamicRef",
708
823
  _ => "$ref",
709
824
  };
710
- report.scoped(keyword, None, |r| {
711
- self.collect(
712
- r,
713
- self.reference_target(*target, active),
714
- value,
715
- depth + 1,
716
- scope::Context {
717
- active: Some(&frame),
718
- ..active
719
- },
720
- )
825
+ let target = self.reference_target(*target, active);
826
+ let active = scope::Context {
827
+ active: Some(&frame),
828
+ ..active
829
+ };
830
+ let result = report.scoped(keyword, None, |r| {
831
+ if tracked {
832
+ self.collect_kind::<true>(r, target, value, depth + 1, active, kind)
833
+ } else {
834
+ self.collect_kind::<false>(r, target, value, depth + 1, active, kind)
835
+ }
721
836
  })?;
722
837
  if tracked {
723
- prior.merge_diagnostics(self.eval_tracked_kind(
724
- self.reference_target(*target, active),
725
- value,
726
- depth + 1,
727
- scope::Context {
728
- active: Some(&frame),
729
- ..active
730
- },
731
- kind,
732
- ))?;
838
+ prior.merge_diagnostics(result)?;
733
839
  }
734
840
  }
735
841
  }
736
842
  }
737
- Ok(())
843
+ let failed = report.failed;
844
+ report.failed |= parent_failed;
845
+ // Diagnostic coverage keeps local attempted locations for subsequent
846
+ // unevaluated checks; only a successful evaluation exports annotations.
847
+ Ok(if failed { Tracked::new(Invalid) } else { prior })
738
848
  }
739
849
 
740
850
  fn needs_diagnostics(
@@ -744,6 +854,9 @@ impl Engine {
744
854
  depth: usize,
745
855
  active: scope::Context<'_>,
746
856
  ) -> Result<bool, Verdict> {
857
+ if !self.budget.checkpoint() {
858
+ return Err(Unsupported);
859
+ }
747
860
  let status = match self.nodes[node].as_slice() {
748
861
  [Op::Number(required, rules)] if depth <= MAX_EVALUATION_DEPTH => {
749
862
  rules.eval_guarded(*required, value)
@@ -773,7 +886,7 @@ impl Engine {
773
886
  }
774
887
 
775
888
  struct ObjectDiagnostics<'a, 'b, 'c> {
776
- engine: &'a Engine,
889
+ engine: &'a Execution<'a>,
777
890
  report: &'b mut Report,
778
891
  rules: &'a Object,
779
892
  depth: usize,
@@ -787,10 +900,13 @@ struct ObjectDiagnostics<'a, 'b, 'c> {
787
900
  unsafe extern "C" fn object_diagnostic(context: *mut c_void, key: Value, value: Value) -> i32 {
788
901
  catch_unwind(AssertUnwindSafe(|| {
789
902
  let c = unsafe { &mut *context.cast::<ObjectDiagnostics<'_, '_, '_>>() };
903
+ if !c.engine.budget.checkpoint() {
904
+ return Unsupported as i32;
905
+ }
790
906
  let visit = |report: &mut Report, child: usize, item: Value, segment: &str, schema_key| {
791
907
  if c.engine.needs_diagnostics(child, item, c.depth, c.active)? {
792
908
  report.property(segment, key, schema_key, |r| {
793
- c.engine.collect(r, child, item, c.depth, c.active)
909
+ c.engine.collect::<false>(r, child, item, c.depth, c.active)
794
910
  })?;
795
911
  }
796
912
  Ok::<_, Verdict>(())
@@ -818,28 +934,14 @@ unsafe extern "C" fn object_diagnostic(context: *mut c_void, key: Value, value:
818
934
  c.coverage.property(c.engine, key);
819
935
  return Ok(());
820
936
  }
821
- let mut matched = false;
822
- if !c.rules.properties.is_empty()
823
- && let Some(&(name, child)) =
824
- c.rules.property_targets.get(&tracking::JsonKey::new(key))
825
- {
826
- matched = true;
827
- visit(c.report, child, value, "properties", Some(name))?;
828
- }
829
- for ((pattern, child), &name) in c.rules.patterns.iter().zip(&c.rules.pattern_names) {
830
- match pattern.eval(key) {
831
- Unsupported => return Err(Unsupported),
832
- Valid => {
833
- matched = true;
834
- visit(c.report, *child, value, "patternProperties", Some(name))?;
835
- }
836
- Invalid => {}
837
- }
838
- }
839
- if !matched && let Some(child) = c.rules.additional {
840
- visit(c.report, child, value, "additionalProperties", None)?;
841
- }
842
- if c.tracked && (matched || c.rules.additional.is_some()) {
937
+ let evaluated = c.rules.visit_value_rules::<true>(
938
+ c.engine.caches,
939
+ &c.engine.budget,
940
+ key,
941
+ None,
942
+ |segment, name, child| visit(c.report, child, value, segment, name),
943
+ )?;
944
+ if c.tracked && evaluated {
843
945
  c.coverage.property(c.engine, key);
844
946
  }
845
947
  Ok(())
@@ -866,23 +968,28 @@ pub struct Record {
866
968
  detail: usize,
867
969
  }
868
970
  /// # Safety
869
- /// Engine and status must be live under the GVL; instance must be a Ruby VALUE.
971
+ /// Engine/status must be live under the GVL; root must be in bounds and
972
+ /// instance live. Caches must be null or live, validator-owned and not in use.
870
973
  #[unsafe(no_mangle)]
871
974
  pub unsafe extern "C" fn sn_diagnose(
872
975
  engine: *const Engine,
976
+ caches: *const pattern::Caches,
977
+ root: usize,
873
978
  instance: Value,
874
979
  status: *mut i32,
875
980
  ) -> *mut Report {
876
981
  let result = catch_unwind(AssertUnwindSafe(|| {
877
982
  let engine = unsafe { &*engine };
983
+ let local = pattern::Caches::default();
984
+ let caches = unsafe { caches.as_ref() }.unwrap_or(&local);
985
+ let execution = Execution {
986
+ engine,
987
+ caches,
988
+ memo: memo::Memo::default(),
989
+ budget: budget::Budget::default(),
990
+ };
878
991
  let mut report = Report::default();
879
- engine.collect(
880
- &mut report,
881
- engine.root,
882
- instance,
883
- 0,
884
- scope::Context::default(),
885
- )?;
992
+ execution.collect::<false>(&mut report, root, instance, 0, scope::Context::default())?;
886
993
  Ok::<_, Verdict>(Box::into_raw(Box::new(report)))
887
994
  }));
888
995
  match result {
@@ -903,6 +1010,69 @@ pub unsafe extern "C" fn sn_diagnose(
903
1010
  }
904
1011
  }
905
1012
  /// # Safety
1013
+ /// Same requirements as sn_diagnose; all limit/output pointers must be live.
1014
+ #[unsafe(no_mangle)]
1015
+ pub unsafe extern "C" fn sn_diagnose_with_limits(
1016
+ engine: *const Engine,
1017
+ caches: *const pattern::Caches,
1018
+ root: usize,
1019
+ instance: Value,
1020
+ status: *mut i32,
1021
+ limits: *const budget::Limits,
1022
+ outcome: *mut budget::Outcome,
1023
+ ) -> *mut Report {
1024
+ let result = catch_unwind(AssertUnwindSafe(|| {
1025
+ let local = pattern::Caches::default();
1026
+ let caches = unsafe { caches.as_ref() }.unwrap_or(&local);
1027
+ let limits = unsafe { *limits };
1028
+ let execution = Execution {
1029
+ engine: unsafe { &*engine },
1030
+ caches,
1031
+ memo: memo::Memo::default(),
1032
+ budget: budget::Budget::new(limits),
1033
+ };
1034
+ let mut report = Report {
1035
+ limits,
1036
+ ..Report::default()
1037
+ };
1038
+ let result = if execution.budget.check_deadline() {
1039
+ execution.collect::<false>(&mut report, root, instance, 0, scope::Context::default())
1040
+ } else {
1041
+ Err(Unsupported)
1042
+ };
1043
+ let result = if execution.budget.check_deadline() {
1044
+ result
1045
+ } else {
1046
+ Err(Unsupported)
1047
+ };
1048
+ let hit = if report.limit_hit.0 != 0 {
1049
+ report.limit_hit
1050
+ } else {
1051
+ execution.budget.hit.get()
1052
+ };
1053
+ unsafe {
1054
+ ((*outcome).kind, (*outcome).attempted) = hit;
1055
+ }
1056
+ result.map(|_| Box::into_raw(Box::new(report)))
1057
+ }));
1058
+ match result {
1059
+ Ok(Ok(report)) => {
1060
+ unsafe { *status = VERDICT_VALID };
1061
+ report
1062
+ }
1063
+ other => {
1064
+ unsafe {
1065
+ *status = if other.is_err() {
1066
+ STATUS_PANIC
1067
+ } else {
1068
+ VERDICT_UNSUPPORTED
1069
+ }
1070
+ };
1071
+ std::ptr::null_mut()
1072
+ }
1073
+ }
1074
+ }
1075
+ /// # Safety
906
1076
  /// Report must be live under the GVL.
907
1077
  #[unsafe(no_mangle)]
908
1078
  pub unsafe extern "C" fn sn_report_len(report: *const Report) -> usize {