Skip to main content

xlog_induce/
nary.rs

1//! Ordered n-ary rule-pattern layer for exact induction.
2//!
3//! Generalizes the four fixed 2-body binary topologies to rule patterns over
4//! heads of arity `>= 1` with bodies of up to `max_body_atoms` atoms, where
5//! every body-atom position binds either a head argument position or a
6//! bounded, canonically-numbered existential join variable. The shipped
7//! binary engine's templates are exactly four members of this space at
8//! `head_arity = 2`, two body atoms and one join variable — see
9//! [`canonical_binary_pattern`], which is the parity surface the general
10//! enumeration is tested against.
11//!
12//! This module is deliberately pure host-side: pattern types, canonical-form
13//! validation and deterministic enumeration. Scoring the patterns on device
14//! is a separate stage that consumes [`NaryRulePattern`] values as its rule
15//! templates.
16//!
17//! Three laws are enforced here rather than documented elsewhere:
18//!
19//! * **Canonical form (join-variable naming).** Join variables are numbered
20//!   densely in first-appearance order across the body read left to right,
21//!   so the ENUMERATOR never emits two patterns differing only by
22//!   join-variable renaming — it does not need to filter them.
23//!
24//!   Note the exact scope: canonicality is a property of what
25//!   [`enumerate_patterns`] EMITS, not of the type. [`NaryRulePattern`] has
26//!   public fields, so a caller can hand-build a non-canonical alpha-twin,
27//!   and the public flatten/score path will score it identically to its
28//!   canonical form. That is safe (the score is the same), but it means
29//!   alpha-duplicates are avoided by construction of the generator and
30//!   REJECTED by [`NaryRulePattern::validate`] — not made unrepresentable
31//!   by the type. Callers assembling patterns by hand own that obligation.
32//!
33//! * **Canonical form does NOT quotient atom order or multiplicity.**
34//!   `H :- A(x),B(x)` and `H :- B(x),A(x)` are distinct members, as are
35//!   `H :- A(x)` and `H :- A(x),A(x)`. Semantically identical bodies score
36//!   identically, tie perfectly in the reduction, can co-occupy top-K, and
37//!   inflate `total_scored`. This matches the shipped binary engine, whose
38//!   ordered `(topology, L, R)` grid likewise scores both orderings; a
39//!   sorted-body canonical form would be a behavior change, not a bug fix.
40//!
41//! * **No silent truncation.** The enumeration refuses with a typed error
42//!   when the pattern space exceeds `max_patterns`, and when the SEARCH
43//!   exceeds `max_traversal_nodes`; it never quietly caps. The two bounds
44//!   are separate because the first bounds output and the second bounds
45//!   work — a search that keeps nothing can still be enormous.
46
47use xlog_core::{Result, XlogError};
48
49/// A variable slot inside a body-atom binding pattern.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
51pub enum PatternVar {
52    /// Bound to head argument position `i` (0-based, `< head_arity`).
53    Head(u8),
54    /// Bound to existential join variable `j` (0-based). Join variables are
55    /// canonical: `j` is dense in first-appearance order across the body.
56    Join(u8),
57}
58
59/// One body atom: a candidate-relation slot plus its ordered bindings.
60///
61/// `candidate_slot` indexes the request's candidate list (the same slot the
62/// binary engine calls `L`/`R` indices); the binding vector length is the
63/// atom's arity and must match the slot's declared relation arity.
64#[derive(Debug, Clone, PartialEq, Eq, Hash)]
65pub struct BodyAtomPattern {
66    pub candidate_slot: u32,
67    pub bindings: Vec<PatternVar>,
68}
69
70/// A canonical n-ary rule pattern: `H(h0..h{a-1}) :- body...`.
71#[derive(Debug, Clone, PartialEq, Eq, Hash)]
72pub struct NaryRulePattern {
73    pub head_arity: u8,
74    pub body: Vec<BodyAtomPattern>,
75}
76
77/// Enumeration bounds for one n-ary induction request.
78#[derive(Debug, Clone, Copy)]
79pub struct NaryEnumerationConfig {
80    /// Maximum number of body atoms per pattern (the binary engine uses 2).
81    pub max_body_atoms: u8,
82    /// Maximum distinct existential join variables per pattern (binary: 1).
83    pub max_join_vars: u8,
84    /// Hard ceiling on the enumerated pattern count. Exceeding it is a typed
85    /// refusal, never a silent cap.
86    ///
87    /// This bounds the OUTPUT. It does not bound the work: the search can
88    /// walk an exponential number of dead-end nodes without ever producing
89    /// a keepable pattern, so `max_traversal_nodes` bounds that separately.
90    pub max_patterns: u32,
91    /// Hard ceiling on SEARCH NODES visited during enumeration.
92    ///
93    /// Without it an in-contract request (small `max_patterns`, wide
94    /// candidate set) walks billions of nodes and returns an empty result,
95    /// because the pattern cap can only fire when a pattern is KEPT.
96    /// Exceeding this is the same kind of typed refusal.
97    pub max_traversal_nodes: u64,
98}
99
100impl NaryRulePattern {
101    /// Validate canonical form against a head arity, the candidate arity
102    /// table and the enumeration bounds.
103    ///
104    /// The checks, in refusal order:
105    /// 1. structural: nonzero head arity, nonempty body, body length bound;
106    /// 2. per-atom: known candidate slot, binding length == declared arity;
107    /// 3. variable legality: `Head(i) < head_arity`, join count bound;
108    /// 4. canonical join numbering (dense, first-appearance order);
109    /// 5. range restriction: every head position bound somewhere in the
110    ///    body — a head position no body atom touches cannot be scored by
111    ///    coverage and is refused as an unsafe variable rather than scored
112    ///    vacuously.
113    pub fn validate(&self, candidate_arities: &[u8], config: &NaryEnumerationConfig) -> Result<()> {
114        if self.head_arity == 0 {
115            return Err(XlogError::Type(
116                "nary pattern: head arity must be at least 1".into(),
117            ));
118        }
119        if self.body.is_empty() {
120            return Err(XlogError::Type(
121                "nary pattern: body must carry at least one atom".into(),
122            ));
123        }
124        if self.body.len() > config.max_body_atoms as usize {
125            return Err(XlogError::Type(format!(
126                "nary pattern: body has {} atoms but max_body_atoms is {}",
127                self.body.len(),
128                config.max_body_atoms
129            )));
130        }
131        let mut next_join: u8 = 0;
132        let mut head_bound = vec![false; self.head_arity as usize];
133        for (atom_index, atom) in self.body.iter().enumerate() {
134            let declared = candidate_arities
135                .get(atom.candidate_slot as usize)
136                .copied()
137                .ok_or_else(|| {
138                    XlogError::Type(format!(
139                        "nary pattern: body atom {atom_index} names candidate \
140                         slot {} but only {} candidates exist",
141                        atom.candidate_slot,
142                        candidate_arities.len()
143                    ))
144                })?;
145            if atom.bindings.len() != declared as usize {
146                return Err(XlogError::Type(format!(
147                    "nary pattern: body atom {atom_index} binds {} positions \
148                     but candidate slot {} declares arity {declared}",
149                    atom.bindings.len(),
150                    atom.candidate_slot
151                )));
152            }
153            for binding in &atom.bindings {
154                match *binding {
155                    PatternVar::Head(i) => {
156                        if i >= self.head_arity {
157                            return Err(XlogError::Type(format!(
158                                "nary pattern: head position {i} out of range \
159                                 for head arity {}",
160                                self.head_arity
161                            )));
162                        }
163                        head_bound[i as usize] = true;
164                    }
165                    PatternVar::Join(j) => {
166                        if j > next_join {
167                            return Err(XlogError::Type(format!(
168                                "nary pattern: join variable z{j} appears \
169                                 before z{} — join numbering must be dense in \
170                                 first-appearance order",
171                                j.saturating_sub(1)
172                            )));
173                        }
174                        if j == next_join {
175                            // checked: with max_join_vars = 255 a crafted
176                            // 256-join pattern would panic in debug and wrap
177                            // to 0 in release, after which the bound check
178                            // reads 0 > 255 and ACCEPTS the over-budget,
179                            // non-canonical pattern.
180                            next_join = next_join.checked_add(1).ok_or_else(|| {
181                                XlogError::Type(
182                                    "nary pattern: join variable count exceeds                                      the representable range"
183                                        .to_string(),
184                                )
185                            })?;
186                            if next_join > config.max_join_vars {
187                                return Err(XlogError::Type(format!(
188                                    "nary pattern: {} join variables exceed \
189                                     max_join_vars {}",
190                                    next_join, config.max_join_vars
191                                )));
192                            }
193                        }
194                    }
195                }
196            }
197        }
198        if let Some(unbound) = head_bound.iter().position(|bound| !bound) {
199            return Err(XlogError::UnsafeVariable(format!("h{unbound}")));
200        }
201        Ok(())
202    }
203}
204
205/// The four shipped binary topologies expressed as n-ary patterns.
206///
207/// This is the parity surface: scoring these four patterns for a pair of
208/// candidate slots must reproduce the binary engine's `(topology, L, R)`
209/// results exactly.
210pub fn canonical_binary_pattern(
211    topology: crate::types::Topology,
212    left_slot: u32,
213    right_slot: u32,
214) -> NaryRulePattern {
215    use crate::types::Topology;
216    use PatternVar::{Head, Join};
217    let (left, right) = match topology {
218        // H(X,Y) :- L(X,Z), R(Z,Y)
219        Topology::Chain => (vec![Head(0), Join(0)], vec![Join(0), Head(1)]),
220        // H(X,Y) :- L(X,Y), R(X,Y)
221        Topology::Star => (vec![Head(0), Head(1)], vec![Head(0), Head(1)]),
222        // H(X,Y) :- L(X,Z), R(X,Y)
223        Topology::Fanout => (vec![Head(0), Join(0)], vec![Head(0), Head(1)]),
224        // H(X,Y) :- L(X,Y), R(Z,Y)
225        Topology::Fanin => (vec![Head(0), Head(1)], vec![Join(0), Head(1)]),
226    };
227    NaryRulePattern {
228        head_arity: 2,
229        body: vec![
230            BodyAtomPattern {
231                candidate_slot: left_slot,
232                bindings: left,
233            },
234            BodyAtomPattern {
235                candidate_slot: right_slot,
236                bindings: right,
237            },
238        ],
239    }
240}
241
242/// Deterministically enumerate every well-formed canonical pattern.
243///
244/// Order is lexicographic in `(body_len, candidate slots, bindings)` with
245/// `Head(i)` ordered before `Join(j)` at each position, so two calls with the
246/// same inputs return identical vectors. Canonical join numbering is
247/// generated directly (a join slot may only introduce the next unused join
248/// index), so this function never produces an alpha-duplicate — it does
249/// not need to filter them. Callers that construct patterns by hand
250/// bypass that guarantee; `NaryRulePattern::validate` is what rejects a
251/// non-canonical one.
252///
253/// Refuses with a typed error the moment the pattern count would exceed
254/// `config.max_patterns`.
255pub fn enumerate_patterns(
256    head_arity: u8,
257    candidate_arities: &[u8],
258    config: &NaryEnumerationConfig,
259) -> Result<Vec<NaryRulePattern>> {
260    if head_arity == 0 {
261        return Err(XlogError::Type(
262            "nary enumeration: head arity must be at least 1".into(),
263        ));
264    }
265    if candidate_arities.is_empty() {
266        return Ok(Vec::new());
267    }
268    let mut patterns: Vec<NaryRulePattern> = Vec::new();
269    let mut body: Vec<BodyAtomPattern> = Vec::new();
270    let mut nodes: u64 = 0;
271    for body_len in 1..=config.max_body_atoms {
272        enumerate_bodies(
273            head_arity,
274            candidate_arities,
275            config,
276            body_len,
277            &mut body,
278            0,
279            &mut patterns,
280            &mut nodes,
281        )?;
282    }
283    Ok(patterns)
284}
285
286#[expect(
287    clippy::too_many_arguments,
288    reason = "recursive body enumeration carries immutable search bounds and mutable frontier/output counters explicitly"
289)]
290fn enumerate_bodies(
291    head_arity: u8,
292    candidate_arities: &[u8],
293    config: &NaryEnumerationConfig,
294    body_len: u8,
295    body: &mut Vec<BodyAtomPattern>,
296    joins_used: u8,
297    out: &mut Vec<NaryRulePattern>,
298    nodes: &mut u64,
299) -> Result<()> {
300    charge_node(config, nodes)?;
301    if body.len() == body_len as usize {
302        let candidate = NaryRulePattern {
303            head_arity,
304            body: body.clone(),
305        };
306        // Range restriction is the only law the canonical generator cannot
307        // guarantee positionally; everything else holds by construction.
308        let mut head_bound = vec![false; head_arity as usize];
309        for atom in &candidate.body {
310            for binding in &atom.bindings {
311                if let PatternVar::Head(i) = *binding {
312                    head_bound[i as usize] = true;
313                }
314            }
315        }
316        if head_bound.iter().all(|bound| *bound) {
317            if out.len() as u32 >= config.max_patterns {
318                return Err(XlogError::Execution(format!(
319                    "nary enumeration: pattern space exceeds max_patterns {} \
320                     (head_arity {head_arity}, {} candidates, max_body_atoms \
321                     {}, max_join_vars {}); raise the bound explicitly or \
322                     narrow the request — the engine never truncates silently",
323                    config.max_patterns,
324                    candidate_arities.len(),
325                    config.max_body_atoms,
326                    config.max_join_vars
327                )));
328            }
329            out.push(candidate);
330        }
331        return Ok(());
332    }
333    for slot in 0..candidate_arities.len() as u32 {
334        let arity = candidate_arities[slot as usize];
335        let mut bindings: Vec<PatternVar> = Vec::with_capacity(arity as usize);
336        enumerate_bindings(
337            head_arity,
338            candidate_arities,
339            config,
340            body_len,
341            body,
342            joins_used,
343            slot,
344            arity,
345            &mut bindings,
346            out,
347            nodes,
348        )?;
349    }
350    Ok(())
351}
352
353#[expect(
354    clippy::too_many_arguments,
355    reason = "recursive binding enumeration carries immutable search bounds and mutable partial-pattern state explicitly"
356)]
357fn enumerate_bindings(
358    head_arity: u8,
359    candidate_arities: &[u8],
360    config: &NaryEnumerationConfig,
361    body_len: u8,
362    body: &mut Vec<BodyAtomPattern>,
363    joins_used: u8,
364    slot: u32,
365    arity: u8,
366    bindings: &mut Vec<PatternVar>,
367    out: &mut Vec<NaryRulePattern>,
368    nodes: &mut u64,
369) -> Result<()> {
370    charge_node(config, nodes)?;
371    if bindings.len() == arity as usize {
372        body.push(BodyAtomPattern {
373            candidate_slot: slot,
374            bindings: bindings.clone(),
375        });
376        let joins_now = joins_used.max(next_join_index(body));
377        enumerate_bodies(
378            head_arity,
379            candidate_arities,
380            config,
381            body_len,
382            body,
383            joins_now,
384            out,
385            nodes,
386        )?;
387        body.pop();
388        return Ok(());
389    }
390    for head in 0..head_arity {
391        bindings.push(PatternVar::Head(head));
392        enumerate_bindings(
393            head_arity,
394            candidate_arities,
395            config,
396            body_len,
397            body,
398            joins_used,
399            slot,
400            arity,
401            bindings,
402            out,
403            nodes,
404        )?;
405        bindings.pop();
406    }
407    // Canonical growth: a join slot may reuse any join already introduced or
408    // introduce exactly the next unused index, never a later one.
409    let introduced_so_far = joins_used.max(bindings_join_watermark(bindings, joins_used));
410    let reachable = introduced_so_far
411        .saturating_add(1)
412        .min(config.max_join_vars);
413    for join in 0..reachable {
414        bindings.push(PatternVar::Join(join));
415        enumerate_bindings(
416            head_arity,
417            candidate_arities,
418            config,
419            body_len,
420            body,
421            joins_used,
422            slot,
423            arity,
424            bindings,
425            out,
426            nodes,
427        )?;
428        bindings.pop();
429    }
430    Ok(())
431}
432
433/// Charge one search node against the traversal budget.
434///
435/// The pattern cap can only fire when a pattern is KEPT, so a request with
436/// a small `max_patterns` over a wide candidate set would otherwise walk an
437/// exponential dead-end space and return an empty result after minutes of
438/// work. Bounding nodes makes the refusal reachable on the SEARCH, not just
439/// on the output.
440fn charge_node(config: &NaryEnumerationConfig, nodes: &mut u64) -> Result<()> {
441    *nodes += 1;
442    if *nodes > config.max_traversal_nodes {
443        return Err(XlogError::Execution(format!(
444            "nary enumeration: search exceeded max_traversal_nodes {} \
445             (max_body_atoms {}, max_join_vars {}); the pattern space is \
446             larger than the work budget — narrow the request or raise the \
447             bound explicitly, the engine never truncates silently",
448            config.max_traversal_nodes, config.max_body_atoms, config.max_join_vars,
449        )));
450    }
451    Ok(())
452}
453
454/// Highest join index introduced anywhere in `body`, as a next-free counter.
455fn next_join_index(body: &[BodyAtomPattern]) -> u8 {
456    let mut next = 0u8;
457    for atom in body {
458        for binding in &atom.bindings {
459            if let PatternVar::Join(j) = *binding {
460                next = next.max(j + 1);
461            }
462        }
463    }
464    next
465}
466
467/// Highest join index introduced in the partial `bindings`, as a next-free
468/// counter floored at `joins_used` (joins introduced by earlier atoms).
469fn bindings_join_watermark(bindings: &[PatternVar], joins_used: u8) -> u8 {
470    let mut next = joins_used;
471    for binding in bindings {
472        if let PatternVar::Join(j) = *binding {
473            next = next.max(j + 1);
474        }
475    }
476    next
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482    use crate::types::Topology;
483
484    fn config(max_body_atoms: u8, max_join_vars: u8) -> NaryEnumerationConfig {
485        NaryEnumerationConfig {
486            max_body_atoms,
487            max_join_vars,
488            max_patterns: 1_000_000,
489            max_traversal_nodes: 1_000_000,
490        }
491    }
492
493    #[test]
494    fn canonical_binary_patterns_validate_and_match_templates() {
495        let arities = [2u8, 2u8];
496        let cfg = config(2, 1);
497        for topology in Topology::ALL {
498            let pattern = canonical_binary_pattern(topology, 0, 1);
499            pattern
500                .validate(&arities, &cfg)
501                .unwrap_or_else(|e| panic!("{topology:?} failed validation: {e}"));
502            assert_eq!(pattern.head_arity, 2);
503            assert_eq!(pattern.body.len(), 2);
504        }
505        // Chain joins through z on the inner positions - the exact shipped
506        // template H(X,Y) :- L(X,Z), R(Z,Y).
507        let chain = canonical_binary_pattern(Topology::Chain, 0, 1);
508        assert_eq!(
509            chain.body[0].bindings,
510            vec![PatternVar::Head(0), PatternVar::Join(0)]
511        );
512        assert_eq!(
513            chain.body[1].bindings,
514            vec![PatternVar::Join(0), PatternVar::Head(1)]
515        );
516    }
517
518    #[test]
519    fn enumeration_contains_all_four_binary_topologies() {
520        let arities = [2u8, 2u8];
521        let cfg = config(2, 1);
522        let patterns = enumerate_patterns(2, &arities, &cfg).expect("enumerate");
523        for topology in Topology::ALL {
524            let expected = canonical_binary_pattern(topology, 0, 1);
525            assert!(
526                patterns.contains(&expected),
527                "{topology:?} missing from the general enumeration"
528            );
529        }
530    }
531
532    #[test]
533    fn enumeration_is_deterministic_and_canonical() {
534        let arities = [2u8, 3u8];
535        let cfg = config(2, 2);
536        let first = enumerate_patterns(3, &arities, &cfg).expect("enumerate");
537        let second = enumerate_patterns(3, &arities, &cfg).expect("enumerate");
538        assert_eq!(first, second);
539        for pattern in &first {
540            pattern
541                .validate(&arities, &cfg)
542                .unwrap_or_else(|e| panic!("non-canonical pattern {pattern:?}: {e}"));
543        }
544    }
545
546    #[test]
547    fn enumeration_count_matches_hand_derivation() {
548        // head_arity=1, one arity-1 candidate, max_body=2, max_join=1.
549        // K=1: (H0) — a lone (J0) leaves the head unbound and is dropped.
550        // K=2: (H0,H0), (H0,J0), (J0,H0); (J0,J0) drops (head unbound),
551        // (J0,J1) is unreachable at max_join=1. Total = 4.
552        let arities = [1u8];
553        let cfg = config(2, 1);
554        let patterns = enumerate_patterns(1, &arities, &cfg).expect("enumerate");
555        assert_eq!(
556            patterns.len(),
557            4,
558            "hand-derived space changed: {patterns:#?}"
559        );
560    }
561
562    #[test]
563    fn validation_refuses_out_of_range_head_position() {
564        let pattern = NaryRulePattern {
565            head_arity: 2,
566            body: vec![BodyAtomPattern {
567                candidate_slot: 0,
568                bindings: vec![PatternVar::Head(2), PatternVar::Head(0)],
569            }],
570        };
571        let err = pattern.validate(&[2], &config(2, 1)).unwrap_err();
572        assert!(matches!(err, XlogError::Type(_)), "got {err:?}");
573    }
574
575    #[test]
576    fn validation_refuses_unbound_head_position_as_unsafe_variable() {
577        let pattern = NaryRulePattern {
578            head_arity: 2,
579            body: vec![BodyAtomPattern {
580                candidate_slot: 0,
581                bindings: vec![PatternVar::Head(0), PatternVar::Join(0)],
582            }],
583        };
584        let err = pattern.validate(&[2], &config(2, 1)).unwrap_err();
585        assert!(matches!(err, XlogError::UnsafeVariable(v) if v == "h1"));
586    }
587
588    #[test]
589    fn validation_refuses_non_dense_join_numbering() {
590        let pattern = NaryRulePattern {
591            head_arity: 1,
592            body: vec![BodyAtomPattern {
593                candidate_slot: 0,
594                bindings: vec![PatternVar::Head(0), PatternVar::Join(1)],
595            }],
596        };
597        let err = pattern.validate(&[2], &config(2, 2)).unwrap_err();
598        assert!(matches!(err, XlogError::Type(_)), "got {err:?}");
599    }
600
601    #[test]
602    fn validation_refuses_arity_mismatch_against_declared_candidate() {
603        let pattern = NaryRulePattern {
604            head_arity: 1,
605            body: vec![BodyAtomPattern {
606                candidate_slot: 0,
607                bindings: vec![PatternVar::Head(0)],
608            }],
609        };
610        let err = pattern.validate(&[3], &config(2, 1)).unwrap_err();
611        assert!(matches!(err, XlogError::Type(_)), "got {err:?}");
612    }
613
614    #[test]
615    fn pattern_space_guard_refuses_instead_of_truncating() {
616        let arities = [2u8, 2u8];
617        let cfg = NaryEnumerationConfig {
618            max_body_atoms: 2,
619            max_join_vars: 1,
620            max_patterns: 3,
621            max_traversal_nodes: 1_000_000,
622        };
623        let err = enumerate_patterns(2, &arities, &cfg).unwrap_err();
624        assert!(
625            matches!(err, XlogError::Execution(ref m) if m.contains("max_patterns")),
626            "got {err:?}"
627        );
628    }
629
630    #[test]
631    fn traversal_guard_fires_on_work_not_only_on_output() {
632        // The reviewer's probe: an IN-CONTRACT request whose pattern cap can
633        // never fire, because the search walks an enormous dead-end space
634        // without keeping anything. Before the node budget this returned
635        // Ok(empty) after millions of nodes; now it refuses.
636        let arities = [2u8; 8];
637        let cfg = NaryEnumerationConfig {
638            max_body_atoms: 8,
639            max_join_vars: 8,
640            max_patterns: 1,
641            max_traversal_nodes: 10_000,
642        };
643        let err = enumerate_patterns(8, &arities, &cfg).unwrap_err();
644        assert!(
645            matches!(
646                err,
647                XlogError::Execution(ref m) if m.contains("max_traversal_nodes")
648            ),
649            "got {err:?}"
650        );
651    }
652
653    #[test]
654    fn traversal_budget_does_not_refuse_a_bounded_request() {
655        // The guard must not fire on lawful small requests — a check that
656        // can fail in BOTH directions.
657        let arities = [2u8, 2u8];
658        let cfg = NaryEnumerationConfig {
659            max_body_atoms: 2,
660            max_join_vars: 1,
661            max_patterns: 1_000,
662            max_traversal_nodes: 1_000_000,
663        };
664        let patterns = enumerate_patterns(2, &arities, &cfg).expect("bounded request");
665        assert!(!patterns.is_empty());
666    }
667}