1use xlog_core::{Result, XlogError};
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
51pub enum PatternVar {
52 Head(u8),
54 Join(u8),
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Hash)]
65pub struct BodyAtomPattern {
66 pub candidate_slot: u32,
67 pub bindings: Vec<PatternVar>,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Hash)]
72pub struct NaryRulePattern {
73 pub head_arity: u8,
74 pub body: Vec<BodyAtomPattern>,
75}
76
77#[derive(Debug, Clone, Copy)]
79pub struct NaryEnumerationConfig {
80 pub max_body_atoms: u8,
82 pub max_join_vars: u8,
84 pub max_patterns: u32,
91 pub max_traversal_nodes: u64,
98}
99
100impl NaryRulePattern {
101 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 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
205pub 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 Topology::Chain => (vec![Head(0), Join(0)], vec![Join(0), Head(1)]),
220 Topology::Star => (vec![Head(0), Head(1)], vec![Head(0), Head(1)]),
222 Topology::Fanout => (vec![Head(0), Join(0)], vec![Head(0), Head(1)]),
224 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
242pub 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 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 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
433fn 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
454fn 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
467fn 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 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 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 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 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}