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
@@ -0,0 +1,240 @@
1
+ //! Exact reachable scope dependencies, shared across schema components.
2
+ use super::*;
3
+ use std::borrow::Cow;
4
+ use std::collections::HashSet;
5
+ use std::sync::Arc;
6
+
7
+ const MAX_NAMES: usize = 65536;
8
+
9
+ #[derive(Default)]
10
+ pub(super) struct Projection {
11
+ names: Vec<Option<Arc<[usize]>>>,
12
+ // If materialized closures exceed the budget, retain the linear dependency
13
+ // graph and compute those exact projections on demand, never all names.
14
+ graph: Option<Vec<Vec<usize>>>,
15
+ }
16
+ impl Projection {
17
+ pub(super) fn compile(edges: &[Vec<usize>], parents: &[Vec<usize>], count: usize) -> Self {
18
+ Self::bounded(edges, parents, count, MAX_NAMES)
19
+ }
20
+ fn bounded(edges: &[Vec<usize>], parents: &[Vec<usize>], count: usize, limit: usize) -> Self {
21
+ let mut seen = vec![false; edges.len()];
22
+ let order = graph::postorder(edges, &mut seen);
23
+ let mut component = vec![usize::MAX; edges.len()];
24
+ let mut members = vec![];
25
+ for root in order.into_iter().rev() {
26
+ if component[root] != usize::MAX {
27
+ continue;
28
+ }
29
+ let id = members.len();
30
+ let mut nodes = vec![];
31
+ let mut stack = vec![root];
32
+ component[root] = id;
33
+ while let Some(node) = stack.pop() {
34
+ nodes.push(node);
35
+ for &parent in &parents[node] {
36
+ if component[parent] == usize::MAX {
37
+ component[parent] = id;
38
+ stack.push(parent);
39
+ }
40
+ }
41
+ }
42
+ members.push(nodes);
43
+ }
44
+ // Kosaraju components are ordered from sources to sinks. Every member
45
+ // of a cycle has the same reachable reference names; solve it once.
46
+ let mut closures: Vec<Option<Arc<[usize]>>> = vec![None; members.len()];
47
+ let mut interned: HashSet<Arc<[usize]>> = HashSet::new();
48
+ let mut slots = 0;
49
+ for (id, nodes) in members.iter().enumerate().rev() {
50
+ let mut names = vec![];
51
+ let mut children = HashSet::new();
52
+ let mut dependencies = HashSet::new();
53
+ let mut complete = true;
54
+ for &node in nodes {
55
+ if node >= count {
56
+ names.push(node - count);
57
+ }
58
+ for &child in &edges[node] {
59
+ let child = component[child];
60
+ if child != id && children.insert(child) {
61
+ match &closures[child] {
62
+ Some(dependency) => {
63
+ if dependencies.insert(dependency.as_ptr()) {
64
+ names.extend(dependency.iter().copied());
65
+ }
66
+ }
67
+ None => complete = false,
68
+ }
69
+ if names.len() > limit {
70
+ names.sort_unstable();
71
+ names.dedup();
72
+ if names.len() > limit {
73
+ complete = false;
74
+ break;
75
+ }
76
+ }
77
+ }
78
+ }
79
+ // Bound temporary concatenation too. Deduplicate overlapping
80
+ // dependencies before choosing the on-demand representation.
81
+ if names.len() > limit {
82
+ names.sort_unstable();
83
+ names.dedup();
84
+ if names.len() > limit {
85
+ complete = false;
86
+ break;
87
+ }
88
+ }
89
+ }
90
+ if !complete {
91
+ continue;
92
+ }
93
+ names.sort_unstable();
94
+ names.dedup();
95
+ if let Some(existing) = interned.get(names.as_slice()) {
96
+ closures[id] = Some(existing.clone());
97
+ } else if names.len() <= limit - slots {
98
+ slots += names.len();
99
+ let names: Arc<[usize]> = names.into();
100
+ interned.insert(names.clone());
101
+ closures[id] = Some(names);
102
+ }
103
+ }
104
+ let names: Vec<_> = component[..count]
105
+ .iter()
106
+ .map(|&id| closures[id].clone())
107
+ .collect();
108
+ let graph = names.iter().any(Option::is_none).then(|| edges.to_vec());
109
+ Self { names, graph }
110
+ }
111
+ pub(super) fn names(
112
+ &self,
113
+ node: usize,
114
+ count: usize,
115
+ anchors: usize,
116
+ guarded: bool,
117
+ budget: &budget::Budget,
118
+ ) -> Option<Cow<'_, [usize]>> {
119
+ if self.names.is_empty() {
120
+ // Hand-built engines in key tests have no compiled dependency graph.
121
+ return Some(Cow::Owned((0..anchors + usize::from(guarded)).collect()));
122
+ }
123
+ if let Some(names) = &self.names[node] {
124
+ return Some(Cow::Borrowed(names));
125
+ }
126
+ let graph = self.graph.as_ref()?;
127
+ let mut seen = vec![false; graph.len()];
128
+ let mut stack = vec![node];
129
+ let mut names = vec![];
130
+ seen[node] = true;
131
+ while let Some(node) = stack.pop() {
132
+ if !budget.checkpoint() {
133
+ return None;
134
+ }
135
+ if node >= count {
136
+ names.push(node - count);
137
+ }
138
+ for &child in &graph[node] {
139
+ if !seen[child] {
140
+ seen[child] = true;
141
+ stack.push(child);
142
+ }
143
+ }
144
+ }
145
+ names.sort_unstable();
146
+ Some(Cow::Owned(names))
147
+ }
148
+ pub(super) fn memory_usage(&self) -> usize {
149
+ let mut seen = HashSet::new();
150
+ self.names.capacity() * std::mem::size_of::<Option<Arc<[usize]>>>()
151
+ + self
152
+ .names
153
+ .iter()
154
+ .flatten()
155
+ .filter(|names| seen.insert(names.as_ptr()))
156
+ .map(|names| (names.len() + 2) * std::mem::size_of::<usize>())
157
+ .sum::<usize>()
158
+ + self.graph.as_ref().map_or(0, |graph| {
159
+ graph.capacity() * std::mem::size_of::<Vec<usize>>()
160
+ + graph
161
+ .iter()
162
+ .map(|row| row.capacity() * std::mem::size_of::<usize>())
163
+ .sum::<usize>()
164
+ })
165
+ }
166
+ }
167
+
168
+ #[cfg(test)]
169
+ mod tests {
170
+ use super::*;
171
+
172
+ fn parents(edges: &[Vec<usize>]) -> Vec<Vec<usize>> {
173
+ let mut parents = vec![vec![]; edges.len()];
174
+ for (node, children) in edges.iter().enumerate() {
175
+ for &child in children {
176
+ parents[child].push(node);
177
+ }
178
+ }
179
+ parents
180
+ }
181
+ #[test]
182
+ fn projects_cycles_and_transitive_dynamic_dispatch_targets() {
183
+ // Four nodes, two dynamic names and the recursive dispatch vertex.
184
+ let edges = vec![
185
+ vec![1],
186
+ vec![0, 4],
187
+ vec![5],
188
+ vec![],
189
+ vec![2],
190
+ vec![3],
191
+ vec![0],
192
+ ];
193
+ for limit in [0, 1, MAX_NAMES] {
194
+ let projection = Projection::bounded(&edges, &parents(&edges), 4, limit);
195
+ let names = |node| {
196
+ projection
197
+ .names(node, 4, 2, true, &budget::Budget::default())
198
+ .unwrap()
199
+ .into_owned()
200
+ };
201
+ assert_eq!(names(0), [0, 1]);
202
+ assert_eq!(names(1), [0, 1]);
203
+ assert_eq!(names(2), [1]);
204
+ assert!(names(3).is_empty());
205
+ }
206
+ }
207
+ #[test]
208
+ fn retains_recursive_dispatch_only_where_reachable() {
209
+ let edges = vec![vec![1], vec![4], vec![3], vec![], vec![0]];
210
+ let projection = Projection::compile(&edges, &parents(&edges), 3);
211
+ let names = |node| {
212
+ projection
213
+ .names(node, 3, 1, true, &budget::Budget::default())
214
+ .unwrap()
215
+ .into_owned()
216
+ };
217
+ assert_eq!(names(0), [1]);
218
+ assert_eq!(names(1), [1]);
219
+ assert_eq!(names(2), [0]);
220
+ }
221
+
222
+ #[test]
223
+ fn shares_equal_closures_and_retains_exact_projection_after_budget_exhaustion() {
224
+ let edges = vec![vec![1, 2], vec![3], vec![3], vec![4], vec![], vec![]];
225
+ let projection = Projection::bounded(&edges, &parents(&edges), 4, MAX_NAMES);
226
+ assert!(projection.graph.is_none());
227
+ assert!(Arc::ptr_eq(
228
+ projection.names[0].as_ref().unwrap(),
229
+ projection.names[3].as_ref().unwrap()
230
+ ));
231
+ let projection = Projection::bounded(&edges, &parents(&edges), 4, 0);
232
+ assert!(projection.graph.is_some());
233
+ assert_eq!(
234
+ &*projection
235
+ .names(0, 4, 1, false, &budget::Budget::default())
236
+ .unwrap(),
237
+ &[0]
238
+ );
239
+ }
240
+ }
@@ -96,14 +96,13 @@ pub(super) fn integer_fingerprint(integer: Value) -> u64 {
96
96
  let mut words = [0_u64; 16];
97
97
  let sign = unsafe { sn_integer1024(integer, words.as_mut_ptr()) };
98
98
  if sign.abs() > 1 {
99
- // No finite Float can equal this magnitude. Mix bounded low words,
100
- // sign, and magnitude size; full equality resolves all collisions,
101
- // including integers that differ only in unsampled high digits.
102
- let len = words
103
- .iter()
104
- .rposition(|&word| word != 0)
105
- .map_or(0, |index| index + 1);
106
- return integer_bucket(&words[..len], sign, unsafe { sn_integer_bytes(integer) });
99
+ // No finite Float can equal this magnitude, so the full native digit
100
+ // hash needs no Float normalization. Sampling only low digits makes
101
+ // mixed/nested uniqueItems quadratic for differences above bit 1024
102
+ // and collapses large enum indexes into a single candidate bucket.
103
+ // The adapter hashes the original Bignum without Ruby dispatch or
104
+ // allocation; exact equality still decides every hash collision.
105
+ return mix(big_only_fingerprint(integer).unwrap() ^ TYPE_NUMBER as u64);
107
106
  }
108
107
  exact_integer_fingerprint(&words, sign)
109
108
  }
@@ -4,29 +4,150 @@
4
4
  use super::*;
5
5
  use regex_automata::{
6
6
  Anchored, Input, MatchKind,
7
- dfa::{Automaton, StartKind, dense},
8
- meta::Regex,
7
+ dfa::{Automaton, OverlappingState, StartKind, dense},
8
+ meta::{Cache, Regex},
9
9
  nfa::thompson,
10
10
  };
11
11
  use regex_syntax::ast::{Ast, RepetitionKind, RepetitionRange};
12
12
 
13
+ // Each validator owns its search caches. Compiled regex/DFA data stays shared.
14
+ #[derive(Default)]
15
+ pub struct Caches(std::cell::RefCell<Vec<Option<Box<Cache>>>>);
16
+ impl Caches {
17
+ fn is_match(&self, index: usize, regex: &Regex, bytes: &[u8]) -> bool {
18
+ // Each runner is tied to one engine, whose regex indices are fixed at
19
+ // compilation. Allocate only the caches this validator actually uses.
20
+ let mut caches = self.0.borrow_mut();
21
+ if caches.len() <= index {
22
+ caches.resize_with(index + 1, || None);
23
+ }
24
+ let cache = caches[index].get_or_insert_with(|| Box::new(regex.create_cache()));
25
+ regex
26
+ .search_half_with(cache, &Input::new(bytes).earliest(true))
27
+ .is_some()
28
+ }
29
+ }
30
+ #[unsafe(no_mangle)]
31
+ pub extern "C" fn sn_caches_new() -> *mut Caches {
32
+ Box::into_raw(Box::new(Caches::default()))
33
+ }
34
+ /// # Safety
35
+ /// Caches must be owned, returned by sn_caches_new, and freed exactly once.
36
+ #[unsafe(no_mangle)]
37
+ pub unsafe extern "C" fn sn_caches_free(caches: *mut Caches) {
38
+ drop(unsafe { Box::from_raw(caches) });
39
+ }
40
+ /// # Safety
41
+ /// Caches must be live; call under the GVL or while all Ractors are stopped.
42
+ #[unsafe(no_mangle)]
43
+ pub unsafe extern "C" fn sn_caches_size(caches: *const Caches) -> usize {
44
+ // GC may inspect a stopped runner while its search has borrowed a cache.
45
+ let Ok(caches) = (unsafe { &*caches }).0.try_borrow() else {
46
+ return std::mem::size_of::<Caches>();
47
+ };
48
+ std::mem::size_of::<Caches>()
49
+ + caches.capacity() * std::mem::size_of::<Option<Box<Cache>>>()
50
+ + caches
51
+ .iter()
52
+ .flatten()
53
+ .map(|cache| std::mem::size_of::<Cache>() + cache.memory_usage())
54
+ .sum::<usize>()
55
+ }
56
+
13
57
  #[derive(Debug)]
14
58
  pub(super) enum Pattern {
15
59
  Unsupported,
16
60
  Dfa(Box<dense::DFA<Vec<u32>>>),
17
- Compiled(Regex),
61
+ Compiled { regex: Regex, index: usize },
62
+ }
63
+
64
+ // Preserve every overlapping match while scanning a key with one automaton.
65
+ // Keep individual matchers for unsupported compilation/search configurations.
66
+ #[derive(Debug)]
67
+ pub(super) struct PatternGroup {
68
+ dfa: dense::DFA<Vec<u32>>,
69
+ all: u64,
70
+ }
71
+ impl PatternGroup {
72
+ pub(super) fn compile(names: &[Value], patterns: &[(Pattern, usize)]) -> Option<Self> {
73
+ if !(2..=64).contains(&patterns.len())
74
+ || !patterns
75
+ .iter()
76
+ .all(|(pattern, _)| matches!(pattern, Pattern::Dfa(_)))
77
+ {
78
+ return None;
79
+ }
80
+ // Existing DFA compilation already proved syntax, encoding, compatibility
81
+ // and full start anchoring. Reuse exactly the same translation.
82
+ let sources: Option<Vec<_>> = names
83
+ .iter()
84
+ .map(|&name| {
85
+ std::str::from_utf8(unsafe { string_bytes(name) })
86
+ .ok()
87
+ .and_then(translate)
88
+ })
89
+ .collect();
90
+ let dfa = dense::Builder::new()
91
+ .configure(
92
+ dense::Config::new()
93
+ .match_kind(MatchKind::All)
94
+ .start_kind(StartKind::Anchored)
95
+ .dfa_size_limit(Some(1 << 20))
96
+ .determinize_size_limit(Some(1 << 22)),
97
+ )
98
+ .thompson(thompson::Config::new().nfa_size_limit(Some(1 << 20)))
99
+ .build_many(&sources?)
100
+ .ok()?;
101
+ let all = u64::MAX >> (64 - patterns.len());
102
+ Some(Self { dfa, all })
103
+ }
104
+ pub(super) fn matches(&self, value: Value) -> Option<u64> {
105
+ let mut data = std::ptr::null();
106
+ let mut len = 0;
107
+ let encoding = unsafe { sn_pattern_view(value, &mut data, &mut len) };
108
+ if encoding == ENCODING_OTHER {
109
+ return None;
110
+ }
111
+ let bytes = unsafe { byte_view(data, len) };
112
+ if encoding != ENCODING_SEVEN_BIT
113
+ && ((encoding == ENCODING_ASCII && !bytes.is_ascii())
114
+ || std::str::from_utf8(bytes).is_err())
115
+ {
116
+ return None;
117
+ }
118
+ let input = Input::new(bytes).anchored(Anchored::Yes);
119
+ let mut state = OverlappingState::start();
120
+ let mut matches = 0;
121
+ loop {
122
+ self.dfa
123
+ .try_search_overlapping_fwd(&input, &mut state)
124
+ .ok()?;
125
+ let Some(found) = state.get_match() else {
126
+ break;
127
+ };
128
+ matches |= 1_u64 << found.pattern().as_usize();
129
+ if matches == self.all {
130
+ break;
131
+ }
132
+ }
133
+ Some(matches)
134
+ }
135
+ pub(super) fn memory_usage(&self) -> usize {
136
+ self.dfa.memory_usage()
137
+ }
18
138
  }
19
139
 
20
140
  impl Pattern {
21
141
  // Select the crate matcher once for a homogeneous array. The borrowed
22
142
  // Ruby slices are used synchronously; classification and encoding are
23
143
  // still checked on every element, including non-string instances.
24
- pub(super) fn eval_items(&self, required: i32, values: &[Value]) -> Verdict {
25
- self.visit_invalid_items(required, values, |_, _| Invalid)
144
+ pub(super) fn eval_items(&self, caches: &Caches, required: i32, values: &[Value]) -> Verdict {
145
+ self.visit_invalid_items(caches, required, values, |_, _| Invalid)
26
146
  }
27
147
 
28
148
  pub(super) fn visit_invalid_items(
29
149
  &self,
150
+ caches: &Caches,
30
151
  required: i32,
31
152
  values: &[Value],
32
153
  invalid: impl FnMut(usize, Value) -> Verdict,
@@ -39,8 +160,8 @@ impl Pattern {
39
160
  Err(_) => Unsupported,
40
161
  }
41
162
  }),
42
- Self::Compiled(regex) => scan_items(required, values, invalid, |bytes| {
43
- verdict(regex.is_match(bytes))
163
+ Self::Compiled { regex, index } => scan_items(required, values, invalid, |bytes| {
164
+ verdict(caches.is_match(*index, regex, bytes))
44
165
  }),
45
166
  Self::Unsupported => scan_items(required, values, invalid, |_| Unsupported),
46
167
  }
@@ -49,12 +170,12 @@ impl Pattern {
49
170
  pub(super) fn memory_usage(&self) -> usize {
50
171
  match self {
51
172
  Self::Dfa(dfa) => std::mem::size_of_val(&**dfa) + dfa.memory_usage(),
52
- Self::Compiled(regex) => regex.memory_usage(),
173
+ Self::Compiled { regex, .. } => regex.memory_usage(),
53
174
  Self::Unsupported => 0,
54
175
  }
55
176
  }
56
177
 
57
- pub(super) fn compile(value: Value) -> Self {
178
+ pub(super) fn compile(value: Value, cache_count: &mut usize) -> Self {
58
179
  if unsafe { sn_kind(value) } != TYPE_STRING {
59
180
  return Self::Unsupported;
60
181
  }
@@ -105,11 +226,15 @@ impl Pattern {
105
226
  .hybrid_cache_capacity(1 << 20),
106
227
  )
107
228
  .build(&source)
108
- .map(Self::Compiled)
229
+ .map(|regex| {
230
+ let index = *cache_count;
231
+ *cache_count += 1;
232
+ Self::Compiled { regex, index }
233
+ })
109
234
  .unwrap_or(Self::Unsupported)
110
235
  }
111
236
 
112
- pub(super) fn eval(&self, value: Value) -> Verdict {
237
+ pub(super) fn eval(&self, caches: &Caches, value: Value) -> Verdict {
113
238
  if matches!(self, Self::Unsupported) {
114
239
  return Unsupported;
115
240
  }
@@ -120,10 +245,10 @@ impl Pattern {
120
245
  return Unsupported;
121
246
  }
122
247
  let bytes = unsafe { std::slice::from_raw_parts(data, len) };
123
- self.eval_bytes(bytes, encoding)
248
+ self.eval_bytes(caches, bytes, encoding)
124
249
  }
125
250
 
126
- pub(super) fn eval_bytes(&self, bytes: &[u8], encoding: i32) -> Verdict {
251
+ pub(super) fn eval_bytes(&self, caches: &Caches, bytes: &[u8], encoding: i32) -> Verdict {
127
252
  if encoding == ENCODING_OTHER || matches!(self, Self::Unsupported) {
128
253
  return Unsupported;
129
254
  }
@@ -140,7 +265,7 @@ impl Pattern {
140
265
  Ok(matched) => matched.is_some(),
141
266
  Err(_) => return Unsupported,
142
267
  },
143
- Self::Compiled(regex) => regex.is_match(bytes),
268
+ Self::Compiled { regex, index } => caches.is_match(*index, regex, bytes),
144
269
  Self::Unsupported => return Unsupported,
145
270
  };
146
271
  if matched { Valid } else { Invalid }
@@ -286,3 +411,21 @@ fn compatible(ast: &Ast) -> bool {
286
411
  _ => true,
287
412
  }
288
413
  }
414
+
415
+ #[cfg(test)]
416
+ mod tests {
417
+ use super::*;
418
+
419
+ #[test]
420
+ fn reports_memory_when_a_search_cache_is_borrowed() {
421
+ let caches = Caches::default();
422
+ let regex = Regex::new("value[0-9]+").unwrap();
423
+ assert!(caches.is_match(0, &regex, b"value1"));
424
+ assert!(unsafe { sn_caches_size(&caches) } > std::mem::size_of::<Caches>());
425
+ let _search = caches.0.borrow_mut();
426
+ assert_eq!(
427
+ unsafe { sn_caches_size(&caches) },
428
+ std::mem::size_of::<Caches>()
429
+ );
430
+ }
431
+ }