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
@@ -0,0 +1,1149 @@
1
+ //! Invocation-local results for shared reference targets and guard/scope contexts.
2
+ use super::*;
3
+ use std::cell::RefCell;
4
+ use tracking::Tracked;
5
+
6
+ pub(super) type Key = (usize, Value, usize);
7
+ const MAX_SCOPE_SLOTS: usize = 65536;
8
+ const MAX_ENTRIES: usize = 4096;
9
+ const MAX_ANNOTATION_SLOTS: usize = 65536;
10
+
11
+ // Evict old results instead of permanently refusing new results once an
12
+ // invocation reaches a limit. FIFO admission keeps later instance locations
13
+ // eligible for reuse, with constant expected work per admission/eviction.
14
+ struct Cache<K, T> {
15
+ results: HashMap<K, (T, usize)>,
16
+ order: std::collections::VecDeque<K>,
17
+ slots: usize,
18
+ }
19
+ impl<K, T> Default for Cache<K, T> {
20
+ fn default() -> Self {
21
+ Self {
22
+ results: HashMap::new(),
23
+ order: std::collections::VecDeque::new(),
24
+ slots: 0,
25
+ }
26
+ }
27
+ }
28
+ impl<K: Copy + Eq + std::hash::Hash, T: Clone> Cache<K, T> {
29
+ fn get(&self, key: K) -> Option<T> {
30
+ self.results.get(&key).map(|(result, _)| result.clone())
31
+ }
32
+ fn record(&mut self, key: K, result: &T, slots: usize, max_entries: usize) {
33
+ // A single oversized result must not flush useful small results.
34
+ if slots > MAX_ANNOTATION_SLOTS || self.results.contains_key(&key) {
35
+ return;
36
+ }
37
+ while self.results.len() >= max_entries || slots > MAX_ANNOTATION_SLOTS - self.slots {
38
+ let oldest = self.order.pop_front().unwrap();
39
+ let (_, removed) = self.results.remove(&oldest).unwrap();
40
+ self.slots -= removed;
41
+ }
42
+ self.results.insert(key, (result.clone(), slots));
43
+ self.order.push_back(key);
44
+ self.slots += slots;
45
+ }
46
+ }
47
+
48
+ // Exact effective bindings identify a scope, rather than its stack address or
49
+ // a probabilistic digest. FIFO eviction bounds the signature buffers; IDs are
50
+ // never reused, so still-running evaluations can safely record older keys.
51
+ #[derive(Default)]
52
+ struct Scopes {
53
+ bindings: HashMap<std::sync::Arc<[Option<usize>]>, usize>,
54
+ order: std::collections::VecDeque<std::sync::Arc<[Option<usize>]>>,
55
+ slots: usize,
56
+ next_id: usize,
57
+ }
58
+ impl Scopes {
59
+ fn intern(&mut self, bindings: Vec<Option<usize>>) -> Option<usize> {
60
+ if let Some(&id) = self.bindings.get(bindings.as_slice()) {
61
+ return Some(id);
62
+ }
63
+ if bindings.len() > MAX_SCOPE_SLOTS {
64
+ return None;
65
+ }
66
+ let id = self.next_id.checked_add(1)?;
67
+ while self.bindings.len() >= MAX_ENTRIES || bindings.len() > MAX_SCOPE_SLOTS - self.slots {
68
+ let oldest = self.order.pop_front().unwrap();
69
+ self.bindings.remove(oldest.as_ref());
70
+ self.slots -= oldest.len();
71
+ }
72
+ let bindings: std::sync::Arc<[Option<usize>]> = bindings.into();
73
+ self.slots += bindings.len();
74
+ self.bindings.insert(bindings.clone(), id);
75
+ self.order.push_back(bindings);
76
+ self.next_id = id;
77
+ Some(id)
78
+ }
79
+ }
80
+
81
+ // Approximate repeat detection only decides whether to attempt caching.
82
+ // False positives are safe: verdict/annotation keys remain fully exact.
83
+ // Reset epochs bound density and keep later locations eligible for admission.
84
+ #[derive(Default)]
85
+ struct Admission {
86
+ bits: Vec<u64>,
87
+ admissions: usize,
88
+ }
89
+ impl Admission {
90
+ fn admit(&mut self, (node, value, depth): Key) -> bool {
91
+ if self.bits.is_empty() {
92
+ self.bits.resize(1024, 0);
93
+ }
94
+ if self.admissions == MAX_ENTRIES {
95
+ self.bits.fill(0);
96
+ self.admissions = 0;
97
+ }
98
+ let hash =
99
+ mix(node as u64 ^ (value as u64).rotate_left(21) ^ (depth as u64).rotate_left(42));
100
+ let a = hash as usize & 65535;
101
+ let b = (hash >> 32) as usize & 65535;
102
+ let seen =
103
+ self.bits[a / 64] & (1 << (a % 64)) != 0 && self.bits[b / 64] & (1 << (b % 64)) != 0;
104
+ if !seen {
105
+ self.bits[a / 64] |= 1 << (a % 64);
106
+ self.bits[b / 64] |= 1 << (b % 64);
107
+ self.admissions += 1;
108
+ }
109
+ seen
110
+ }
111
+ }
112
+
113
+ #[derive(Default)]
114
+ pub(super) struct Memo {
115
+ verdicts: RefCell<Cache<Key, Verdict>>,
116
+ scopes: RefCell<Scopes>,
117
+ instances: RefCell<instance_graph::Graph>,
118
+ guard_admission: RefCell<Admission>,
119
+ // None is tracked validity; Some(bool) is the diagnostic annotation policy.
120
+ // Coverage results share both admission order and the allocation budget.
121
+ coverage: RefCell<Cache<(Key, Option<bool>), Tracked>>,
122
+ }
123
+ impl Memo {
124
+ pub(super) fn key(
125
+ &self,
126
+ engine: &Engine,
127
+ node: usize,
128
+ value: Value,
129
+ depth: usize,
130
+ context: scope::Context<'_>,
131
+ budget: &budget::Budget,
132
+ ) -> Option<Key> {
133
+ if !engine.memoizable[node] {
134
+ return None;
135
+ }
136
+ let mut object = false;
137
+ if engine.memo_guarded.get(node).copied().unwrap_or(false) {
138
+ let kind = unsafe { sn_shallow_kind(value) };
139
+ object = kind == TYPE_OBJECT;
140
+ if kind != TYPE_ARRAY && !object && !engine.memo_guard_scalar_amplification[node] {
141
+ return None;
142
+ }
143
+ if !self.admit_guard_key((node, value, depth)) {
144
+ return None;
145
+ }
146
+ }
147
+ self.key_with_instance_proof(
148
+ engine,
149
+ node,
150
+ value,
151
+ depth,
152
+ context,
153
+ (
154
+ object,
155
+ |value| self.instances.borrow_mut().acyclic(value, budget),
156
+ budget,
157
+ ),
158
+ )
159
+ }
160
+ fn admit_guard_key(&self, key: Key) -> bool {
161
+ self.guard_admission.borrow_mut().admit(key)
162
+ }
163
+ // Pure Rust key tests do not link CRuby. They exercise the conservative
164
+ // path; Ruby integration tests exercise the actual bounded instance proof.
165
+ #[cfg(test)]
166
+ fn key_without_instance_proof(
167
+ &self,
168
+ engine: &Engine,
169
+ node: usize,
170
+ value: Value,
171
+ depth: usize,
172
+ context: scope::Context<'_>,
173
+ ) -> Option<Key> {
174
+ self.key_with_instance_proof(
175
+ engine,
176
+ node,
177
+ value,
178
+ depth,
179
+ context,
180
+ (false, |_| false, &budget::Budget::default()),
181
+ )
182
+ }
183
+ fn key_with_instance_proof(
184
+ &self,
185
+ engine: &Engine,
186
+ node: usize,
187
+ value: Value,
188
+ depth: usize,
189
+ context: scope::Context<'_>,
190
+ analysis: (bool, impl FnOnce(Value) -> bool, &budget::Budget),
191
+ ) -> Option<Key> {
192
+ let (object, prove_acyclic, budget) = analysis;
193
+ if !engine.memoizable[node] {
194
+ return None;
195
+ }
196
+ if engine.memo_guarded.get(node).copied().unwrap_or(false) {
197
+ let relevant = engine.memo_scope_projection.names(
198
+ node,
199
+ engine.nodes.len(),
200
+ engine.anchor_tables.len(),
201
+ true,
202
+ budget,
203
+ )?;
204
+ let names = relevant.len();
205
+ if names > MAX_SCOPE_SLOTS - 2 {
206
+ return None;
207
+ }
208
+ if !budget.checkpoint() {
209
+ return None;
210
+ }
211
+ let mut pairs = vec![];
212
+ let mut current = context.active;
213
+ while let Some(frame) = current {
214
+ if !budget.checkpoint() {
215
+ return None;
216
+ }
217
+ // A source outside every schema cycle cannot be revisited from
218
+ // its own descendant evaluation, even under dynamic dispatch.
219
+ if engine.memo_guard_sources[frame.source] {
220
+ if pairs.len() == (MAX_SCOPE_SLOTS - names - 2) / 2 {
221
+ return None;
222
+ }
223
+ pairs.push((frame.source, frame.value));
224
+ }
225
+ current = frame.parent;
226
+ }
227
+ if !pairs.is_empty() && prove_acyclic(value) {
228
+ // An acyclic input cannot revisit ancestors through position
229
+ // changes. Only same-instance schema cycles retain guards.
230
+ let same = if object {
231
+ &engine.memo_guard_same_sources
232
+ } else {
233
+ &engine.memo_guard_scalar_sources
234
+ };
235
+ pairs.retain(|&(source, ancestor)| ancestor == value && same[source]);
236
+ }
237
+ // RefFrame::contains tests membership, independently of order.
238
+ if !budget.checkpoint() {
239
+ return None;
240
+ }
241
+ pairs.sort_unstable();
242
+ pairs.dedup();
243
+ let mut signature = Vec::with_capacity(1 + 2 * pairs.len() + names);
244
+ signature.push(Some(pairs.len()));
245
+ for (source, value) in pairs {
246
+ signature.extend([Some(source), Some(value)]);
247
+ }
248
+ let first_binding = signature.len();
249
+ signature.resize(first_binding + names, None);
250
+ let mut current = context.scope;
251
+ while let Some(frame) = current {
252
+ if !budget.checkpoint() {
253
+ return None;
254
+ }
255
+ for (slot, &name) in relevant.iter().enumerate() {
256
+ if name == engine.anchor_tables.len() {
257
+ if let Some(&root) = engine.resources.get(&frame.resource)
258
+ && engine.metadata[root].recursive_anchor
259
+ {
260
+ signature[first_binding + slot] = Some(root);
261
+ }
262
+ } else if let Some(&target) =
263
+ engine.dynamic_bindings.get(&(frame.resource, name))
264
+ {
265
+ signature[first_binding + slot] = Some(target);
266
+ }
267
+ }
268
+ current = frame.parent;
269
+ }
270
+ if !budget.checkpoint() {
271
+ return None;
272
+ }
273
+ let id = self.scopes.borrow_mut().intern(signature)?;
274
+ let node = id.checked_mul(engine.nodes.len())?.checked_add(node)?;
275
+ return Some((node, value, depth));
276
+ }
277
+ if !engine.memo_contextual[node] {
278
+ return Some((node, value, depth));
279
+ }
280
+ let scope = if engine.anchor_tables.len() == 1 {
281
+ // The existing frame already retains the outermost binding.
282
+ // Zero means unbound; compiled node IDs start at zero.
283
+ context
284
+ .scope
285
+ .and_then(|frame| frame.primary_binding)
286
+ .map_or(0, |target| target + 1)
287
+ } else {
288
+ let relevant = engine.memo_scope_projection.names(
289
+ node,
290
+ engine.nodes.len(),
291
+ engine.anchor_tables.len(),
292
+ false,
293
+ budget,
294
+ )?;
295
+ let names = relevant.len();
296
+ if names > MAX_SCOPE_SLOTS {
297
+ return None;
298
+ }
299
+ if !budget.checkpoint() {
300
+ return None;
301
+ }
302
+ let mut bindings = vec![None; names];
303
+ let mut current = context.scope;
304
+ while let Some(frame) = current {
305
+ if !budget.checkpoint() {
306
+ return None;
307
+ }
308
+ for (&name, binding) in relevant.iter().zip(&mut bindings) {
309
+ if let Some(target) = engine.dynamic_bindings.get(&(frame.resource, name)) {
310
+ *binding = Some(*target);
311
+ }
312
+ }
313
+ current = frame.parent;
314
+ }
315
+ self.scopes.borrow_mut().intern(bindings)?
316
+ };
317
+ // Each scope gets a disjoint block of native node IDs. Checked
318
+ // arithmetic keeps compact three-word result keys without aliasing
319
+ // after scope eviction, even if monotonic IDs eventually overflow.
320
+ let node = scope.checked_mul(engine.nodes.len())?.checked_add(node)?;
321
+ Some((node, value, depth))
322
+ }
323
+ pub(super) fn verdict(&self, key: Key) -> Option<Verdict> {
324
+ self.verdicts.borrow().get(key)
325
+ }
326
+ pub(super) fn record_verdict(&self, key: Key, status: Verdict) {
327
+ self.verdicts
328
+ .borrow_mut()
329
+ .record(key, &status, 0, MAX_ENTRIES);
330
+ }
331
+ pub(super) fn tracked(&self, key: Key) -> Option<Tracked> {
332
+ self.coverage.borrow().get((key, None))
333
+ }
334
+ pub(super) fn record_tracked(&self, key: Key, result: &Tracked) {
335
+ self.coverage.borrow_mut().record(
336
+ (key, None),
337
+ result,
338
+ result.memo_slots(),
339
+ MAX_ENTRIES * 2,
340
+ );
341
+ }
342
+ pub(super) fn diagnostic_success(&self, key: Key, annotations: bool) -> Option<Tracked> {
343
+ self.coverage.borrow().get((key, Some(annotations)))
344
+ }
345
+ pub(super) fn record_diagnostic_success(&self, key: Key, annotations: bool, result: &Tracked) {
346
+ self.coverage.borrow_mut().record(
347
+ (key, Some(annotations)),
348
+ result,
349
+ result.memo_slots(),
350
+ MAX_ENTRIES * 2,
351
+ );
352
+ }
353
+ }
354
+
355
+ #[cfg(test)]
356
+ mod tests {
357
+ use super::*;
358
+
359
+ #[test]
360
+ fn approximate_admission_bounds_density_and_resets_for_later_positions() {
361
+ let mut admission = Admission::default();
362
+ assert!(admission.bits.is_empty());
363
+ assert!(!admission.admit((1, 2, 3)));
364
+ assert!(admission.admit((1, 2, 3)));
365
+ for value in 0..MAX_ENTRIES * 3 {
366
+ admission.admit((4, value, 5));
367
+ assert!(admission.admissions <= MAX_ENTRIES);
368
+ assert!(
369
+ admission
370
+ .bits
371
+ .iter()
372
+ .map(|word| word.count_ones() as usize)
373
+ .sum::<usize>()
374
+ <= MAX_ENTRIES * 2
375
+ );
376
+ }
377
+ assert_eq!(admission.bits.len(), 1024);
378
+ admission.admissions = MAX_ENTRIES;
379
+ assert!(!admission.admit((1, 2, 3)));
380
+ assert!(admission.admit((1, 2, 3)));
381
+ assert_eq!(admission.admissions, 1);
382
+ }
383
+
384
+ #[test]
385
+ fn guarded_keys_distinguish_active_sources_values_scope_order_and_depth() {
386
+ let memo = Memo::default();
387
+ let engine = Engine {
388
+ nodes: (0..3).map(|_| vec![Op::Boolean(true)]).collect(),
389
+ memoizable: vec![true; 3],
390
+ memo_contextual: vec![false; 3],
391
+ memo_guarded: vec![true; 3],
392
+ memo_guard_sources: vec![true; 3],
393
+ memo_guard_same_sources: vec![true; 3],
394
+ memo_guard_scalar_sources: vec![true; 3],
395
+ memo_guard_scalar_amplification: vec![true; 3],
396
+ metadata: vec![
397
+ scope::Metadata {
398
+ recursive_anchor: true,
399
+ ..scope::Metadata::default()
400
+ };
401
+ 3
402
+ ],
403
+ resources: HashMap::from([(1, 0), (2, 1), (42, 2)]),
404
+ ..Engine::default()
405
+ };
406
+ let frame = |source, value| RefFrame {
407
+ source,
408
+ value,
409
+ parent: None,
410
+ };
411
+ let active = frame(1, 42);
412
+ let equivalent = frame(1, 42);
413
+ let other_source = frame(2, 42);
414
+ let other_value = frame(1, 43);
415
+ let context = |active| scope::Context {
416
+ active: Some(active),
417
+ scope: None,
418
+ };
419
+ let key = memo
420
+ .key_without_instance_proof(&engine, 0, 42, 8, context(&active))
421
+ .unwrap();
422
+ assert_eq!(
423
+ memo.key_without_instance_proof(&engine, 0, 42, 8, context(&equivalent)),
424
+ Some(key)
425
+ );
426
+ assert_ne!(
427
+ memo.key_without_instance_proof(&engine, 0, 42, 8, context(&other_source)),
428
+ Some(key)
429
+ );
430
+ assert_ne!(
431
+ memo.key_without_instance_proof(&engine, 0, 43, 8, context(&other_value)),
432
+ Some(key)
433
+ );
434
+ assert_ne!(
435
+ memo.key_without_instance_proof(&engine, 0, 42, 9, context(&active)),
436
+ Some(key)
437
+ );
438
+ let outer = scope::ScopeFrame {
439
+ resource: 1,
440
+ parent: None,
441
+ primary_binding: None,
442
+ };
443
+ let inner = scope::ScopeFrame {
444
+ resource: 2,
445
+ parent: Some(&outer),
446
+ primary_binding: None,
447
+ };
448
+ let swapped_outer = scope::ScopeFrame {
449
+ resource: 2,
450
+ parent: None,
451
+ primary_binding: None,
452
+ };
453
+ let swapped_inner = scope::ScopeFrame {
454
+ resource: 1,
455
+ parent: Some(&swapped_outer),
456
+ primary_binding: None,
457
+ };
458
+ assert_ne!(
459
+ memo.key_without_instance_proof(
460
+ &engine,
461
+ 0,
462
+ 7,
463
+ 8,
464
+ scope::Context {
465
+ scope: Some(&inner),
466
+ ..context(&active)
467
+ }
468
+ ),
469
+ memo.key_without_instance_proof(
470
+ &engine,
471
+ 0,
472
+ 7,
473
+ 8,
474
+ scope::Context {
475
+ scope: Some(&swapped_inner),
476
+ ..context(&active)
477
+ }
478
+ ),
479
+ );
480
+ // The pair count separates guard membership from effective scope
481
+ // bindings even when their numeric handles coincide.
482
+ let a = scope::ScopeFrame {
483
+ resource: 42,
484
+ parent: Some(&outer),
485
+ primary_binding: None,
486
+ };
487
+ assert_ne!(
488
+ Some(key),
489
+ memo.key_without_instance_proof(
490
+ &engine,
491
+ 0,
492
+ 7,
493
+ 8,
494
+ scope::Context {
495
+ active: None,
496
+ scope: Some(&a)
497
+ }
498
+ )
499
+ );
500
+
501
+ let empty = scope::Context::default();
502
+ let other = context(&other_value);
503
+ assert_eq!(
504
+ memo.key_with_instance_proof(
505
+ &engine,
506
+ 0,
507
+ 42,
508
+ 8,
509
+ other,
510
+ (false, |_| true, &budget::Budget::default())
511
+ ),
512
+ memo.key_with_instance_proof(
513
+ &engine,
514
+ 0,
515
+ 42,
516
+ 8,
517
+ empty,
518
+ (false, |_| true, &budget::Budget::default())
519
+ ),
520
+ );
521
+ assert_ne!(
522
+ memo.key_with_instance_proof(
523
+ &engine,
524
+ 0,
525
+ 42,
526
+ 8,
527
+ other,
528
+ (false, |_| false, &budget::Budget::default())
529
+ ),
530
+ memo.key_with_instance_proof(
531
+ &engine,
532
+ 0,
533
+ 42,
534
+ 8,
535
+ empty,
536
+ (false, |_| false, &budget::Budget::default())
537
+ ),
538
+ );
539
+ let a = RefFrame {
540
+ source: 1,
541
+ value: 42,
542
+ parent: Some(&other_source),
543
+ };
544
+ let b = RefFrame {
545
+ source: 2,
546
+ value: 42,
547
+ parent: Some(&active),
548
+ };
549
+ assert_eq!(
550
+ memo.key_without_instance_proof(&engine, 0, 42, 8, context(&a)),
551
+ memo.key_without_instance_proof(&engine, 0, 42, 8, context(&b)),
552
+ );
553
+ }
554
+
555
+ #[test]
556
+ fn keys_exact_outermost_bindings_and_reuses_equivalent_scopes() {
557
+ let memo = Memo::default();
558
+ let engine = Engine {
559
+ nodes: vec![vec![Op::Boolean(true)]],
560
+ memoizable: vec![true],
561
+ memo_contextual: vec![true],
562
+ anchor_tables: vec![vec![], vec![]],
563
+ dynamic_bindings: HashMap::from([((1, 0), 7), ((2, 0), 8), ((2, 1), 9), ((3, 0), 7)]),
564
+ ..Engine::default()
565
+ };
566
+ let frame = |resource, parent| scope::ScopeFrame {
567
+ resource,
568
+ parent,
569
+ primary_binding: None,
570
+ };
571
+ let outer = frame(1, None);
572
+ let inner = frame(2, Some(&outer));
573
+ let equivalent_outer = frame(3, None);
574
+ let equivalent_inner = frame(2, Some(&equivalent_outer));
575
+ let different = frame(2, None);
576
+ let key = |frame| {
577
+ memo.key_without_instance_proof(
578
+ &engine,
579
+ 0,
580
+ 10,
581
+ 2,
582
+ scope::Context {
583
+ scope: Some(frame),
584
+ ..scope::Context::default()
585
+ },
586
+ )
587
+ .unwrap()
588
+ };
589
+ let first = key(&inner);
590
+ assert_eq!(first, key(&equivalent_inner));
591
+ assert_ne!(first, key(&different));
592
+ memo.record_verdict(first, Valid);
593
+ assert_eq!(memo.verdict(key(&equivalent_inner)), Some(Valid));
594
+ assert_eq!(memo.verdict(key(&different)), None);
595
+ assert_eq!(memo.scopes.borrow().bindings.len(), 2);
596
+ }
597
+
598
+ #[test]
599
+ fn projected_keys_ignore_unused_bindings_but_keep_reachable_names() {
600
+ let edges = vec![vec![3], vec![4], vec![], vec![], vec![], vec![]];
601
+ let parents = vec![vec![], vec![], vec![], vec![0], vec![1], vec![]];
602
+ let mut engine = Engine {
603
+ nodes: (0..3).map(|_| vec![Op::Boolean(true)]).collect(),
604
+ memoizable: vec![true; 3],
605
+ memo_contextual: vec![true; 3],
606
+ memo_guarded: vec![false, false, true],
607
+ memo_guard_sources: vec![false; 3],
608
+ memo_scope_projection: memo_scope::Projection::compile(&edges, &parents, 3),
609
+ anchor_tables: vec![vec![], vec![]],
610
+ dynamic_bindings: HashMap::from([((1, 0), 7), ((2, 0), 7), ((1, 1), 8), ((2, 1), 9)]),
611
+ resources: HashMap::from([(1, 0), (2, 1)]),
612
+ metadata: vec![
613
+ scope::Metadata {
614
+ recursive_anchor: true,
615
+ ..scope::Metadata::default()
616
+ };
617
+ 3
618
+ ],
619
+ ..Engine::default()
620
+ };
621
+ let memo = Memo::default();
622
+ let frame = |resource| scope::ScopeFrame {
623
+ resource,
624
+ parent: None,
625
+ primary_binding: None,
626
+ };
627
+ let a = frame(1);
628
+ let b = frame(2);
629
+ let key = |engine: &Engine, node, frame| {
630
+ memo.key_without_instance_proof(
631
+ engine,
632
+ node,
633
+ 10,
634
+ 2,
635
+ scope::Context {
636
+ scope: Some(frame),
637
+ ..scope::Context::default()
638
+ },
639
+ )
640
+ .unwrap()
641
+ };
642
+ assert_eq!(key(&engine, 0, &a), key(&engine, 0, &b));
643
+ assert_ne!(key(&engine, 1, &a), key(&engine, 1, &b));
644
+ // Neither dynamic nor recursive dispatch can affect node 2.
645
+ assert_eq!(key(&engine, 2, &a), key(&engine, 2, &b));
646
+ engine.dynamic_bindings.insert((2, 0), 8);
647
+ assert_ne!(key(&engine, 0, &a), key(&engine, 0, &b));
648
+ }
649
+
650
+ #[test]
651
+ fn compact_scope_blocks_separate_nodes_and_decline_overflow() {
652
+ let engine = Engine {
653
+ nodes: vec![vec![], vec![], vec![]],
654
+ memoizable: vec![true; 3],
655
+ memo_contextual: vec![true, true, false],
656
+ anchor_tables: vec![vec![], vec![]],
657
+ dynamic_bindings: HashMap::from([((1, 0), 0), ((2, 0), 1)]),
658
+ ..Engine::default()
659
+ };
660
+ let first = scope::ScopeFrame {
661
+ resource: 1,
662
+ parent: None,
663
+ primary_binding: None,
664
+ };
665
+ let second = scope::ScopeFrame {
666
+ resource: 2,
667
+ parent: None,
668
+ primary_binding: None,
669
+ };
670
+ let context = |frame| scope::Context {
671
+ scope: Some(frame),
672
+ ..scope::Context::default()
673
+ };
674
+ let memo = Memo::default();
675
+ let a = memo
676
+ .key_without_instance_proof(&engine, 1, 10, 2, context(&first))
677
+ .unwrap();
678
+ let b = memo
679
+ .key_without_instance_proof(&engine, 0, 10, 2, context(&second))
680
+ .unwrap();
681
+ assert_ne!(a, b);
682
+ assert_eq!(
683
+ memo.key_without_instance_proof(&engine, 2, 10, 2, context(&first)),
684
+ Some((2, 10, 2))
685
+ );
686
+ assert_eq!(
687
+ memo.key_without_instance_proof(&engine, 2, 10, 2, context(&second)),
688
+ Some((2, 10, 2))
689
+ );
690
+ let exhausted = Memo::default();
691
+ exhausted.scopes.borrow_mut().next_id = usize::MAX / 2;
692
+ assert!(
693
+ exhausted
694
+ .key_without_instance_proof(&engine, 0, 10, 2, context(&first))
695
+ .is_none()
696
+ );
697
+ assert_eq!(
698
+ exhausted.key_without_instance_proof(&engine, 2, 10, 2, context(&first)),
699
+ Some((2, 10, 2))
700
+ );
701
+ }
702
+
703
+ #[test]
704
+ fn scope_turnover_never_reassigns_ids_still_used_by_results() {
705
+ let mut scopes = Scopes::default();
706
+ let old = scopes.intern(vec![Some(0)]).unwrap();
707
+ for binding in 1..MAX_ENTRIES * 2 {
708
+ scopes.intern(vec![Some(binding)]).unwrap();
709
+ }
710
+ assert_eq!(scopes.bindings.len(), MAX_ENTRIES);
711
+ assert_eq!(scopes.order.len(), MAX_ENTRIES);
712
+ assert_eq!(scopes.slots, MAX_ENTRIES);
713
+ let new = scopes.intern(vec![Some(0)]).unwrap();
714
+ assert_ne!(old, new);
715
+ assert_eq!(scopes.intern(vec![Some(0)]), Some(new));
716
+ let memo = Memo::default();
717
+ memo.record_verdict((old, 2, 3), Invalid);
718
+ assert_eq!(memo.verdict((new, 2, 3)), None);
719
+ }
720
+
721
+ #[test]
722
+ fn bounds_scope_buffers_and_skips_oversized_signatures_without_eviction() {
723
+ let mut scopes = Scopes::default();
724
+ let bindings = vec![Some(0); MAX_SCOPE_SLOTS / 2];
725
+ let first = scopes.intern(bindings.clone()).unwrap();
726
+ scopes.intern(vec![Some(1); bindings.len()]).unwrap();
727
+ scopes.intern(vec![Some(2); bindings.len()]).unwrap();
728
+ assert_eq!(scopes.slots, MAX_SCOPE_SLOTS);
729
+ assert_eq!(scopes.bindings.len(), 2);
730
+ assert!(scopes.intern(vec![None; MAX_SCOPE_SLOTS + 1]).is_none());
731
+ assert_eq!(scopes.slots, MAX_SCOPE_SLOTS);
732
+ assert_ne!(scopes.intern(bindings), Some(first));
733
+ }
734
+
735
+ #[test]
736
+ fn separates_annotation_policies_and_returns_independent_coverage() {
737
+ let memo = Memo::default();
738
+ let key = (1, 2, 3);
739
+ let mut coverage = Tracked::new(Valid);
740
+ coverage.item(65);
741
+ memo.record_diagnostic_success(key, true, &coverage);
742
+ memo.record_diagnostic_success(key, false, &Tracked::new(Valid));
743
+ let mut copy = memo.diagnostic_success(key, true).unwrap();
744
+ copy.item(0);
745
+ assert!(
746
+ memo.diagnostic_success(key, true)
747
+ .unwrap()
748
+ .contains_item(65)
749
+ );
750
+ assert!(!memo.diagnostic_success(key, true).unwrap().contains_item(0));
751
+ assert!(
752
+ !memo
753
+ .diagnostic_success(key, false)
754
+ .unwrap()
755
+ .contains_item(65)
756
+ );
757
+ }
758
+
759
+ #[test]
760
+ fn admits_new_results_after_entry_limits() {
761
+ let memo = Memo::default();
762
+ for node in 0..MAX_ENTRIES * 3 {
763
+ memo.record_verdict((node, 2, 3), Valid);
764
+ memo.record_tracked((node, 2, 3), &Tracked::new(Valid));
765
+ }
766
+ assert!(memo.verdict((0, 2, 3)).is_none());
767
+ assert_eq!(memo.verdict((MAX_ENTRIES * 3 - 1, 2, 3)), Some(Valid));
768
+ assert!(memo.tracked((0, 2, 3)).is_none());
769
+ assert!(memo.tracked((MAX_ENTRIES * 3 - 1, 2, 3)).is_some());
770
+ assert_eq!(memo.verdicts.borrow().results.len(), MAX_ENTRIES);
771
+ assert_eq!(memo.coverage.borrow().results.len(), MAX_ENTRIES * 2);
772
+ assert_eq!(memo.coverage.borrow().order.len(), MAX_ENTRIES * 2);
773
+ }
774
+
775
+ #[test]
776
+ fn skips_oversized_results_without_evicting_or_charging_duplicates() {
777
+ let memo = Memo::default();
778
+ let key = (1, 2, 3);
779
+ let mut small = Tracked::new(Valid);
780
+ small.item(0);
781
+ memo.record_tracked(key, &small);
782
+ memo.record_tracked(key, &small);
783
+ let mut huge = Tracked::new(Valid);
784
+ huge.item(MAX_ANNOTATION_SLOTS * 64);
785
+ memo.record_diagnostic_success((2, 2, 3), true, &huge);
786
+ assert!(memo.tracked(key).is_some());
787
+ assert!(memo.diagnostic_success((2, 2, 3), true).is_none());
788
+ assert_eq!(memo.coverage.borrow().slots, small.memo_slots());
789
+ assert_eq!(memo.coverage.borrow().order.len(), 1);
790
+ }
791
+
792
+ #[test]
793
+ fn bounds_retained_annotations_across_both_caches() {
794
+ let memo = Memo::default();
795
+ let mut large = Tracked::new(Valid);
796
+ large.item(MAX_ANNOTATION_SLOTS * 64 - 1);
797
+ memo.record_tracked((1, 2, 3), &large);
798
+ assert!(memo.tracked((1, 2, 3)).is_some());
799
+ let mut small = Tracked::new(Valid);
800
+ small.item(0);
801
+ memo.record_diagnostic_success((2, 2, 3), true, &small);
802
+ assert!(memo.diagnostic_success((2, 2, 3), true).is_some());
803
+ assert!(memo.tracked((1, 2, 3)).is_none());
804
+ assert_eq!(memo.coverage.borrow().slots, small.memo_slots());
805
+ memo.record_diagnostic_success((3, 2, 3), false, &Tracked::new(Valid));
806
+ assert!(memo.diagnostic_success((3, 2, 3), false).is_some());
807
+ }
808
+ }
809
+
810
+ impl Engine {
811
+ pub(super) fn compile_memoization(&mut self) {
812
+ let mut incoming = vec![0_usize; self.nodes.len()];
813
+ let mut repeated_position = vec![false; self.nodes.len()];
814
+ // Each dynamic name has one virtual dispatch vertex. Sharing it avoids
815
+ // materializing reference-count × binding-count dependency edges.
816
+ let count = self.nodes.len();
817
+ let recursive_dispatch = count + self.anchor_tables.len();
818
+ let mut eligible = vec![true; recursive_dispatch + 1];
819
+ let mut unsupported = vec![false; eligible.len()];
820
+ let mut edges = vec![vec![]; eligible.len()];
821
+ let mut repeated_subtree = vec![false; eligible.len()];
822
+ for (name, bindings) in self.anchor_tables.iter().enumerate() {
823
+ edges[count + name].extend(bindings.iter().map(|&(_, target)| target));
824
+ }
825
+ edges[recursive_dispatch].extend(
826
+ self.resources
827
+ .values()
828
+ .copied()
829
+ .filter(|&root| self.metadata[root].recursive_anchor),
830
+ );
831
+ for (index, ops) in self.nodes.iter().enumerate() {
832
+ for op in ops {
833
+ match op {
834
+ Op::Unsupported => {
835
+ eligible[index] = false;
836
+ unsupported[index] = true;
837
+ }
838
+ Op::Ref(reference) => {
839
+ if let Some(target) = reference.static_target() {
840
+ incoming[target] += 1;
841
+ edges[index].push(target);
842
+ } else if let Some((name, target)) = reference.dynamic_target() {
843
+ incoming[target] += 1;
844
+ edges[index].extend([target, count + name]);
845
+ } else if let Some(target) = reference.recursive_target() {
846
+ incoming[target] += 1;
847
+ edges[index].extend([target, recursive_dispatch]);
848
+ // Compact scope-only keys do not encode recursive
849
+ // dispatch or active guards. Use exact guard contexts.
850
+ eligible[index] = false;
851
+ }
852
+ }
853
+ Op::All(nodes) => edges[index].extend(nodes),
854
+ Op::Any(nodes) | Op::One(nodes) => {
855
+ edges[index].extend(nodes.iter().map(|&(node, _)| node))
856
+ }
857
+ Op::Not(node) => edges[index].push(*node),
858
+ Op::Conditional(condition, yes, no) => {
859
+ edges[index].push(*condition);
860
+ edges[index].extend(yes);
861
+ edges[index].extend(no);
862
+ }
863
+ Op::Array(_, rules) => {
864
+ // One schema edge can evaluate many instance positions.
865
+ // Shared Ruby containers can reach this child repeatedly
866
+ // at the same depth even with only one source reference.
867
+ if !rules.list {
868
+ for &child in &rules.items {
869
+ repeated_position[child] = true;
870
+ repeated_subtree[index] = true;
871
+ }
872
+ }
873
+ for child in [rules.additional, rules.contains, rules.unevaluated]
874
+ .into_iter()
875
+ .flatten()
876
+ {
877
+ repeated_position[child] = true;
878
+ repeated_subtree[index] = true;
879
+ }
880
+ edges[index].extend(&rules.prefix);
881
+ edges[index].extend(&rules.items);
882
+ edges[index].extend(rules.additional);
883
+ edges[index].extend(rules.contains);
884
+ edges[index].extend(rules.unevaluated);
885
+ }
886
+ Op::Object(_, rules) => {
887
+ for &(_, child) in &rules.patterns {
888
+ repeated_position[child] = true;
889
+ repeated_subtree[index] = true;
890
+ }
891
+ for child in [rules.additional, rules.names, rules.unevaluated]
892
+ .into_iter()
893
+ .flatten()
894
+ {
895
+ repeated_position[child] = true;
896
+ repeated_subtree[index] = true;
897
+ }
898
+ edges[index].extend(rules.properties.iter().map(|&(_, child)| child));
899
+ edges[index].extend(rules.patterns.iter().map(|(_, child)| *child));
900
+ edges[index]
901
+ .extend(rules.dependent_schemas.iter().map(|&(_, child)| child));
902
+ edges[index].extend(rules.additional);
903
+ edges[index].extend(rules.names);
904
+ edges[index].extend(rules.unevaluated);
905
+ }
906
+ _ => {}
907
+ }
908
+ }
909
+ }
910
+ // Reject cycles through any possible binding, plus recursive refs.
911
+ // States 2 and 4 distinguish scope-independent and scoped successes.
912
+ // Bound analysis depth just as evaluation is bounded; conservatively
913
+ // leave longer chains uncached rather than risking compilation overflow.
914
+ fn reusable(
915
+ node: usize,
916
+ depth: usize,
917
+ edges: &[Vec<usize>],
918
+ eligible: &[bool],
919
+ states: &mut [u8],
920
+ count: usize,
921
+ repeated_subtree: &mut [bool],
922
+ ) -> bool {
923
+ match states[node] {
924
+ 1 | 3 => return false,
925
+ 2 | 4 => return true,
926
+ _ => {}
927
+ }
928
+ if depth > MAX_EVALUATION_DEPTH || !eligible[node] {
929
+ states[node] = 3;
930
+ return false;
931
+ }
932
+ states[node] = 1;
933
+ let mut scoped = node >= count;
934
+ let safe = edges[node].iter().all(|&child| {
935
+ let safe = reusable(
936
+ child,
937
+ depth + 1,
938
+ edges,
939
+ eligible,
940
+ states,
941
+ count,
942
+ repeated_subtree,
943
+ );
944
+ repeated_subtree[node] |= repeated_subtree[child];
945
+ scoped |= states[child] == 4;
946
+ safe
947
+ });
948
+ states[node] = if !safe {
949
+ 3
950
+ } else if scoped {
951
+ 4
952
+ } else {
953
+ 2
954
+ };
955
+ safe
956
+ }
957
+ let mut states = vec![0; edges.len()];
958
+ self.memoizable = (0..self.nodes.len())
959
+ .map(|node| {
960
+ (incoming[node] > 1 || repeated_position[node])
961
+ && reusable(node, 0, &edges, &eligible, &mut states, count, &mut repeated_subtree)
962
+ // Position-only candidates need nested iteration to amplify
963
+ // sharing. Simple records/scalars keep their cheap scans.
964
+ && (incoming[node] > 1 || repeated_subtree[node])
965
+ })
966
+ .collect();
967
+ self.memo_contextual = states[..count].iter().map(|&state| state == 4).collect();
968
+ // Cyclic candidates can reuse results only under an exact guard/scope
969
+ // context. Propagate unsupported instructions and nested iteration
970
+ // through reverse edges without a per-candidate graph walk.
971
+ let mut parents = vec![vec![]; edges.len()];
972
+ for (parent, children) in edges.iter().enumerate() {
973
+ for &child in children {
974
+ parents[child].push(parent);
975
+ }
976
+ }
977
+ fn propagate(flags: &mut [bool], parents: &[Vec<usize>]) {
978
+ let mut queue: std::collections::VecDeque<_> = flags
979
+ .iter()
980
+ .enumerate()
981
+ .filter_map(|(node, &set)| set.then_some(node))
982
+ .collect();
983
+ while let Some(node) = queue.pop_front() {
984
+ for &parent in &parents[node] {
985
+ if !flags[parent] {
986
+ flags[parent] = true;
987
+ queue.push_back(parent);
988
+ }
989
+ }
990
+ }
991
+ }
992
+ propagate(&mut unsupported, &parents);
993
+ propagate(&mut repeated_subtree, &parents);
994
+ // Iterative Kosaraju analysis identifies the only active sources
995
+ // whose guards can affect descendants: sources lying on a schema cycle.
996
+ fn cycle_nodes(edges: &[Vec<usize>], parents: &[Vec<usize>]) -> Vec<bool> {
997
+ let mut seen = vec![false; edges.len()];
998
+ let order = graph::postorder(edges, &mut seen);
999
+ seen.fill(false);
1000
+ let mut cyclic = vec![false; edges.len()];
1001
+ for root in order.into_iter().rev() {
1002
+ if seen[root] {
1003
+ continue;
1004
+ }
1005
+ let mut component = vec![];
1006
+ let mut stack = vec![root];
1007
+ seen[root] = true;
1008
+ while let Some(node) = stack.pop() {
1009
+ component.push(node);
1010
+ for &parent in &parents[node] {
1011
+ if !seen[parent] {
1012
+ seen[parent] = true;
1013
+ stack.push(parent);
1014
+ }
1015
+ }
1016
+ }
1017
+ if component.len() > 1 || edges[root].contains(&root) {
1018
+ for node in component {
1019
+ cyclic[node] = true;
1020
+ }
1021
+ }
1022
+ }
1023
+ cyclic
1024
+ }
1025
+ let cyclic = cycle_nodes(&edges, &parents);
1026
+ // A root schema has no entry reference. Count references originating
1027
+ // inside cycles separately so two recursive property edges still
1028
+ // establish amplification without requiring a third, external edge.
1029
+ let mut cyclic_incoming = vec![0_usize; count];
1030
+ for (source, ops) in self.nodes.iter().enumerate() {
1031
+ if cyclic[source] {
1032
+ for op in ops {
1033
+ if let Op::Ref(reference) = op {
1034
+ let target = reference
1035
+ .static_target()
1036
+ .or_else(|| reference.dynamic_target().map(|(_, target)| target))
1037
+ .or_else(|| reference.recursive_target());
1038
+ if let Some(target) = target {
1039
+ cyclic_incoming[target] += 1;
1040
+ }
1041
+ }
1042
+ }
1043
+ }
1044
+ }
1045
+ let mut same_edges = vec![vec![]; edges.len()];
1046
+ same_edges[count..].clone_from_slice(&edges[count..]);
1047
+ let mut scalar_refs = vec![false; edges.len()];
1048
+ for (node, ops) in self.nodes.iter().enumerate() {
1049
+ for op in ops {
1050
+ match op {
1051
+ Op::Ref(reference) => {
1052
+ scalar_refs[node] = true;
1053
+ if let Some(target) = reference.static_target() {
1054
+ same_edges[node].push(target);
1055
+ } else if let Some((name, target)) = reference.dynamic_target() {
1056
+ same_edges[node].extend([target, count + name]);
1057
+ } else if let Some(target) = reference.recursive_target() {
1058
+ same_edges[node].extend([target, recursive_dispatch]);
1059
+ }
1060
+ }
1061
+ Op::All(children) => same_edges[node].extend(children),
1062
+ Op::Any(children) | Op::One(children) => {
1063
+ same_edges[node].extend(children.iter().map(|&(child, _)| child))
1064
+ }
1065
+ Op::Not(child) => same_edges[node].push(*child),
1066
+ Op::Conditional(condition, yes, no) => {
1067
+ same_edges[node].push(*condition);
1068
+ same_edges[node].extend(yes);
1069
+ same_edges[node].extend(no);
1070
+ }
1071
+ _ => {}
1072
+ }
1073
+ }
1074
+ }
1075
+ fn reverse(edges: &[Vec<usize>]) -> Vec<Vec<usize>> {
1076
+ let mut parents = vec![vec![]; edges.len()];
1077
+ for (parent, children) in edges.iter().enumerate() {
1078
+ for &child in children {
1079
+ parents[child].push(parent);
1080
+ }
1081
+ }
1082
+ parents
1083
+ }
1084
+ let scalar_parents = reverse(&same_edges);
1085
+ self.memo_guard_scalar_sources =
1086
+ cycle_nodes(&same_edges, &scalar_parents)[..count].to_vec();
1087
+ propagate(&mut scalar_refs, &scalar_parents);
1088
+ let mut scalar_amplification = vec![false; edges.len()];
1089
+ for (node, ops) in self.nodes.iter().enumerate() {
1090
+ for op in ops {
1091
+ let references = match op {
1092
+ Op::All(children) => {
1093
+ children.iter().filter(|&&child| scalar_refs[child]).count()
1094
+ }
1095
+ Op::Any(children) | Op::One(children) => children
1096
+ .iter()
1097
+ .filter(|&&(child, _)| scalar_refs[child])
1098
+ .count(),
1099
+ Op::Conditional(condition, yes, no) => std::iter::once(*condition)
1100
+ .chain(*yes)
1101
+ .chain(*no)
1102
+ .filter(|&child| scalar_refs[child])
1103
+ .count(),
1104
+ _ => 0,
1105
+ };
1106
+ scalar_amplification[node] |= references > 1;
1107
+ }
1108
+ }
1109
+ propagate(&mut scalar_amplification, &scalar_parents);
1110
+ self.memo_guard_scalar_amplification = scalar_amplification[..count].to_vec();
1111
+ // Object dependencies keep the same value; all other container child
1112
+ // edges change positions. Scalar/array inputs cannot take dependencies.
1113
+ for (node, ops) in self.nodes.iter().enumerate() {
1114
+ for op in ops {
1115
+ if let Op::Object(_, rules) = op {
1116
+ same_edges[node]
1117
+ .extend(rules.dependent_schemas.iter().map(|&(_, child)| child));
1118
+ }
1119
+ }
1120
+ }
1121
+ self.memo_guard_same_sources =
1122
+ cycle_nodes(&same_edges, &reverse(&same_edges))[..count].to_vec();
1123
+ self.memo_guard_sources = cyclic[..count].to_vec();
1124
+ self.memo_guarded = vec![false; count];
1125
+ for node in 0..count {
1126
+ let amplified_cycle = incoming[node] > 2 || cyclic_incoming[node] > 1;
1127
+ if !self.memoizable[node] && !unsupported[node]
1128
+ && (incoming[node] > 1 || repeated_position[node] && repeated_subtree[node])
1129
+ // A linear recursive property chain has no amplification.
1130
+ // Avoid signatures unless a cycle has repeated input positions
1131
+ // or multiple recursive edges, with or without an entry ref.
1132
+ && (!cyclic[node] || repeated_subtree[node] || amplified_cycle)
1133
+ // Position loops already admit their child instruction. Do not
1134
+ // duplicate caching at its singly referenced recursive target.
1135
+ && (!cyclic[node] || repeated_position[node] || amplified_cycle || scalar_amplification[node])
1136
+ {
1137
+ self.memoizable[node] = true;
1138
+ self.memo_guarded[node] = true;
1139
+ }
1140
+ }
1141
+ self.memo_scope_projection = if self.memo_guarded.iter().any(|&guarded| guarded)
1142
+ || self.anchor_tables.len() > 1 && self.memo_contextual.iter().any(|&scoped| scoped)
1143
+ {
1144
+ memo_scope::Projection::compile(&edges, &parents, count)
1145
+ } else {
1146
+ memo_scope::Projection::default()
1147
+ };
1148
+ }
1149
+ }