1use std::collections::{HashMap, HashSet};
4
5use crate::types::{
6 BodyAtom, OrderingExpression, Rule, Term, Variable, VariableKind,
7 is_canonical_unsigned_decimal, is_record_reference_text,
8};
9
10use crate::logic::profile::{self, ArgKind, ProfilePredicate, Role};
11
12use super::parser::{
13 is_record_reference_variable_name, is_reserved_logic_word, is_text_variable_name, render_term,
14};
15
16pub(super) fn check_safety(rules: &[Rule]) -> Result<(), String> {
17 for rule in rules {
18 if rule.head_terms.iter().any(Term::is_anonymous) {
21 return Err(format!(
22 "anonymous term in head of predicate {}",
23 rule.head_predicate
24 ));
25 }
26 let pos_vars = collect_positive_nonbuiltin_vars(rule);
27 let head_vars = collect_head_vars(rule);
28
29 for hv in &head_vars {
30 if !pos_vars.contains(hv) {
31 return Err(format!(
32 "unsafe variable {:?} in head of predicate {}",
33 hv.0, rule.head_predicate
34 ));
35 }
36 }
37
38 for atom in &rule.body {
42 let constrained = match atom {
43 BodyAtom::Negated { terms, .. } => collect_vars(terms),
44 BodyAtom::NotEqual(a, b) => {
45 if a.is_anonymous() || b.is_anonymous() {
46 return Err("anonymous term in != body atom".into());
47 }
48 let all_v = collect_terms_vars(&[a.clone(), b.clone()]);
49 if all_v.iter().any(|x| !pos_vars.contains(x)) {
50 return Err("unsafe variable in != body atom".into());
51 }
52 continue;
53 }
54 BodyAtom::Compare { left, right, .. } => {
55 validate_static_int_term(left, "Compare left")?;
56 validate_static_int_term(right, "Compare right")?;
57 if left.term().is_anonymous() || right.term().is_anonymous() {
58 return Err("anonymous term in Compare body atom".into());
59 }
60 collect_terms_vars(&[left.term().clone(), right.term().clone()])
61 }
62 BodyAtom::TextMatch { text, .. } => {
63 if text.is_anonymous() {
64 return Err("anonymous term in TextMatch body atom".into());
65 }
66 collect_vars(std::slice::from_ref(text))
67 }
68 BodyAtom::TextShape {
69 text, start, end, ..
70 } => {
71 if text.is_anonymous() || start.is_anonymous() || end.is_anonymous() {
72 return Err("anonymous term in TextShape body atom".into());
73 }
74 collect_terms_vars(&[text.clone(), start.clone(), end.clone()])
75 }
76 BodyAtom::Cardinality {
77 predicate, terms, ..
78 } => {
79 if !predicate_allows_cardinality(predicate, terms.len()) {
80 return Err(format!(
81 "Cardinality cannot count {}/{}",
82 predicate,
83 terms.len()
84 ));
85 }
86 let mut leaking = collect_vars(terms);
87 leaking.retain(|v| pos_vars.contains(v));
88 leaking
89 }
90 BodyAtom::TopK {
91 predicate,
92 terms,
93 partition,
94 order,
95 ..
96 } => {
97 validate_static_int_term(order, "TopK ordering")?;
98 if !predicate_allows_topk(predicate, terms.len()) {
99 return Err(format!("TopK cannot select {}/{}", predicate, terms.len()));
100 }
101 validate_topk_partition(partition, terms)?;
102 if order.term().is_anonymous() || !terms.iter().any(|term| term == order.term())
103 {
104 return Err("TopK ordering term must occur in the candidate atom and must not be anonymous".into());
105 }
106 collect_vars(terms)
107 }
108 _ => continue,
109 };
110 for v in &constrained {
111 if !pos_vars.contains(v) {
112 return Err(format!(
113 "unsafe variable {:?} in body of rule for {}",
114 v.0, rule.head_predicate
115 ));
116 }
117 }
118 }
119 }
120
121 validate_predicate_names_and_shadowing(rules)?;
122 check_variable_kinds(rules)?;
123
124 Ok(())
125}
126
127fn validate_static_int_term(expression: &OrderingExpression, context: &str) -> Result<(), String> {
128 let OrderingExpression::Int(Term::Const(value)) = expression else {
129 return Ok(());
130 };
131 if is_canonical_unsigned_decimal(value) {
132 Ok(())
133 } else {
134 Err(format!(
135 "{context} Int constant must be canonical unsigned decimal text"
136 ))
137 }
138}
139
140fn validate_predicate_names_and_shadowing(rules: &[Rule]) -> Result<(), String> {
141 let definitions = rules
142 .iter()
143 .map(|rule| (rule.head_predicate.as_str(), rule.head_terms.len()))
144 .collect::<HashSet<_>>();
145 for rule in rules {
146 validate_head_predicate(rule.head_predicate.as_str(), rule.head_terms.len())?;
147 validate_body_predicate_names(&rule.body)?;
148 for (predicate, terms, _) in rule.body.iter().filter_map(BodyAtom::relation) {
149 if predicate_name_class(predicate) == PredicateNameClass::Lowercase
150 && !definitions.contains(&(predicate.as_str(), terms.len()))
151 {
152 return Err(format!("undefined helper {predicate}/{}", terms.len()));
153 }
154 }
155 }
156 Ok(())
157}
158
159fn validate_body_predicate_names(body: &[BodyAtom]) -> Result<(), String> {
160 for atom in body {
161 match atom {
162 BodyAtom::Positive { predicate, terms } | BodyAtom::Negated { predicate, terms } => {
163 validate_body_predicate(predicate, terms.len())?;
164 }
165 BodyAtom::Cardinality {
166 predicate, terms, ..
167 } => {
168 validate_body_predicate(predicate, terms.len())?;
169 if !predicate_allows_cardinality(predicate, terms.len()) {
170 return Err(format!(
171 "Cardinality cannot count {}/{}",
172 predicate,
173 terms.len()
174 ));
175 }
176 }
177 BodyAtom::TopK {
178 predicate, terms, ..
179 } => {
180 validate_body_predicate(predicate, terms.len())?;
181 if !predicate_allows_topk(predicate, terms.len()) {
182 return Err(format!("TopK cannot select {}/{}", predicate, terms.len()));
183 }
184 }
185 BodyAtom::Compare { .. }
186 | BodyAtom::TextShape { .. }
187 | BodyAtom::TextMatch { .. }
188 | BodyAtom::NotEqual(_, _)
189 | BodyAtom::True => {}
190 }
191 }
192 Ok(())
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196enum KindPosition {
197 Record,
198 RecordReference,
199 TextOnly,
200 GenericText,
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204enum HelperArgumentKind {
205 Text,
206 RecordReference,
207 GenericText,
208}
209
210type InferredHelperArgumentKinds = HashMap<(String, usize), Vec<Option<HelperArgumentKind>>>;
211
212fn check_variable_kinds(rules: &[Rule]) -> Result<(), String> {
213 infer_helper_kinds(rules).map(|_| ())
214}
215
216fn infer_helper_kinds(rules: &[Rule]) -> Result<InferredHelperArgumentKinds, String> {
217 let mut helper_kinds = InferredHelperArgumentKinds::new();
218 let defined_helpers = rules
219 .iter()
220 .filter(|rule| profile::lookup(rule.head_predicate.as_str()).is_none())
221 .map(|rule| (rule.head_predicate.to_string(), rule.head_terms.len()))
222 .collect::<HashSet<_>>();
223 for rule in rules {
224 if profile::lookup(rule.head_predicate.as_str()).is_none() {
225 helper_kinds
226 .entry((rule.head_predicate.to_string(), rule.head_terms.len()))
227 .or_insert_with(|| vec![None; rule.head_terms.len()]);
228 }
229 for (name, terms, _) in rule.body.iter().filter_map(BodyAtom::relation) {
230 if profile::lookup(name).is_none() {
231 helper_kinds
232 .entry((name.to_string(), terms.len()))
233 .or_insert_with(|| vec![None; terms.len()]);
234 }
235 }
236 }
237
238 let mut changed = true;
239 while changed {
240 changed = false;
241 for rule in rules {
242 let mut vars = HashMap::<Variable, HelperArgumentKind>::new();
248 if apply_atom_kind_constraints(&rule.body, &helper_kinds, &mut vars).is_err() {
249 continue;
250 }
251 let head_key = (rule.head_predicate.to_string(), rule.head_terms.len());
252 if let Some(sig) = helper_kinds.get_mut(&head_key) {
253 for (idx, term) in rule.head_terms.iter().enumerate() {
254 if let Some(kind) = term_inferred_kind(term, &vars)
255 && merge_kind(&mut sig[idx], kind)
256 {
257 changed = true;
258 }
259 }
260 }
261 }
262 }
263
264 for (key, sig) in helper_kinds.iter_mut() {
265 if !defined_helpers.contains(key) {
266 continue;
267 }
268 for kind in sig.iter_mut() {
269 if kind.is_none() {
270 *kind = Some(HelperArgumentKind::Text);
271 }
272 }
273 }
274
275 for rule in rules {
276 let mut vars = HashMap::<Variable, HelperArgumentKind>::new();
277 constrain_predicate_terms(
278 rule.head_predicate.as_str(),
279 &rule.head_terms,
280 &helper_kinds,
281 &mut vars,
282 )?;
283 apply_atom_kind_constraints(&rule.body, &helper_kinds, &mut vars)?;
284 }
285 Ok(helper_kinds)
286}
287
288pub fn predicate_variable_kinds(
291 rules: &[Rule],
292) -> Result<HashMap<(String, usize), Vec<VariableKind>>, String> {
293 let mut kinds = infer_helper_kinds(rules)?
294 .into_iter()
295 .map(|(key, kinds)| {
296 let kinds = kinds
297 .into_iter()
298 .map(|kind| match kind {
299 Some(HelperArgumentKind::RecordReference) => VariableKind::Record,
300 _ => VariableKind::Text,
301 })
302 .collect();
303 (key, kinds)
304 })
305 .collect::<HashMap<_, _>>();
306 for rule in rules {
307 for (name, terms, _) in rule.body.iter().filter_map(BodyAtom::relation) {
308 let anonymous = vec![Term::Anonymous; terms.len()];
311 if let Some(mark) = standard_predicate_mark(name, terms.len(), &anonymous) {
312 kinds.insert(
313 (name.to_string(), terms.len()),
314 mark.into_iter()
315 .map(|kind| match kind {
316 KindPosition::Record | KindPosition::RecordReference => {
317 VariableKind::Record
318 }
319 _ => VariableKind::Text,
320 })
321 .collect(),
322 );
323 }
324 }
325 }
326 Ok(kinds)
327}
328
329fn apply_atom_kind_constraints(
330 body: &[BodyAtom],
331 helper_kinds: &InferredHelperArgumentKinds,
332 vars: &mut HashMap<Variable, HelperArgumentKind>,
333) -> Result<(), String> {
334 for atom in body {
335 if let Some((predicate, terms, _)) = atom.relation() {
336 constrain_predicate_terms(predicate, terms, helper_kinds, vars)?;
337 }
338 match atom {
339 BodyAtom::TopK { order, .. } => {
340 let position = match order {
341 OrderingExpression::Lex(_) => KindPosition::GenericText,
342 OrderingExpression::Int(_) => KindPosition::TextOnly,
343 OrderingExpression::AdmitOrder(_) => KindPosition::Record,
344 };
345 constrain_term(order.term(), position, vars)?;
346 }
347 BodyAtom::Compare { left, right, .. } => {
348 let position = match left {
349 OrderingExpression::Lex(_) => KindPosition::GenericText,
350 OrderingExpression::Int(_) => KindPosition::TextOnly,
351 OrderingExpression::AdmitOrder(_) => KindPosition::Record,
352 };
353 constrain_term(left.term(), position, vars)?;
354 constrain_term(right.term(), position, vars)?;
355 }
356 BodyAtom::TextMatch { text, .. } => {
357 constrain_term(text, KindPosition::GenericText, vars)?;
358 }
359 BodyAtom::TextShape {
360 text, start, end, ..
361 } => {
362 constrain_term(text, KindPosition::GenericText, vars)?;
363 constrain_term(start, KindPosition::GenericText, vars)?;
364 constrain_term(end, KindPosition::GenericText, vars)?;
365 }
366 BodyAtom::NotEqual(a, b) => {
367 if let (Term::Var(left), Term::Var(right)) = (a, b)
368 && left.kind() != right.kind()
369 {
370 return Err("!= compares incompatible variable kinds".into());
371 }
372 }
373 BodyAtom::Positive { .. }
374 | BodyAtom::Negated { .. }
375 | BodyAtom::Cardinality { .. }
376 | BodyAtom::True => {}
377 }
378 }
379 Ok(())
380}
381
382fn constrain_predicate_terms(
383 name: &str,
384 terms: &[Term],
385 helper_kinds: &InferredHelperArgumentKinds,
386 vars: &mut HashMap<Variable, HelperArgumentKind>,
387) -> Result<(), String> {
388 if let Some(mark) = standard_predicate_mark(name, terms.len(), terms) {
389 for (term, position) in terms.iter().zip(mark) {
390 constrain_term(term, position, vars)?;
391 }
392 return Ok(());
393 }
394 if let Some(mark) = helper_kinds.get(&(name.to_string(), terms.len())) {
395 for (term, kind) in terms.iter().zip(mark) {
396 if let Some(kind) = kind {
397 constrain_term(term, helper_argument_position(*kind), vars)?;
398 }
399 }
400 }
401 Ok(())
402}
403
404fn helper_argument_position(kind: HelperArgumentKind) -> KindPosition {
405 match kind {
406 HelperArgumentKind::Text => KindPosition::TextOnly,
407 HelperArgumentKind::RecordReference => KindPosition::RecordReference,
408 HelperArgumentKind::GenericText => KindPosition::GenericText,
409 }
410}
411
412fn constrain_term(
413 term: &Term,
414 position: KindPosition,
415 vars: &mut HashMap<Variable, HelperArgumentKind>,
416) -> Result<(), String> {
417 match term {
418 Term::Anonymous => Ok(()),
419 Term::Const(value) => {
420 match position {
421 KindPosition::Record => {
422 crate::records::RecordHash::parse(value).map_err(|_| {
423 format!("record-position constant is not a supported record hash: {value}")
424 })?;
425 }
426 KindPosition::RecordReference => {
427 if !is_record_reference_text(value) {
428 return Err(format!(
429 "record-reference constant is not record-reference text: {value}"
430 ));
431 }
432 }
433 KindPosition::TextOnly | KindPosition::GenericText => {}
434 }
435 Ok(())
436 }
437 Term::Var(variable) => {
438 let required = match position {
439 KindPosition::Record | KindPosition::RecordReference => {
440 if variable.kind() != VariableKind::Record {
441 return Err(format!(
442 "{} is text; this position expects a record-reference variable such as `Record`",
443 variable.name()
444 ));
445 }
446 HelperArgumentKind::RecordReference
447 }
448 KindPosition::TextOnly => {
449 if variable.kind() != VariableKind::Text {
450 return Err(format!(
451 "{} is a record-reference variable; this position expects text",
452 render_term(term)
453 ));
454 }
455 HelperArgumentKind::Text
456 }
457 KindPosition::GenericText => HelperArgumentKind::GenericText,
458 };
459 merge_variable_kind(vars, variable.clone(), required)
460 }
461 }
462}
463
464fn term_inferred_kind(
465 term: &Term,
466 vars: &HashMap<Variable, HelperArgumentKind>,
467) -> Option<HelperArgumentKind> {
468 match term {
469 Term::Var(variable) => vars.get(variable).copied().or(Some(match variable.kind() {
470 VariableKind::Record => HelperArgumentKind::RecordReference,
471 VariableKind::Text => HelperArgumentKind::Text,
472 })),
473 Term::Const(value) => {
474 is_record_reference_text(value).then_some(HelperArgumentKind::GenericText)
475 }
476 Term::Anonymous => None,
477 }
478}
479
480fn merge_variable_kind(
481 vars: &mut HashMap<Variable, HelperArgumentKind>,
482 variable: Variable,
483 kind: HelperArgumentKind,
484) -> Result<(), String> {
485 match vars.get_mut(&variable) {
486 Some(existing) => merge_variable_kind_value(existing, kind).map(|_| ()),
487 None => {
488 vars.insert(variable, kind);
489 Ok(())
490 }
491 }
492}
493
494fn merge_variable_kind_value(
495 existing: &mut HelperArgumentKind,
496 incoming: HelperArgumentKind,
497) -> Result<bool, String> {
498 match (*existing, incoming) {
499 (left, right) if left == right => Ok(false),
500 (HelperArgumentKind::GenericText, specific) => {
501 *existing = specific;
502 Ok(true)
503 }
504 (_, HelperArgumentKind::GenericText) => Ok(false),
505 _ => Err("variable used with incompatible kinds".into()),
506 }
507}
508
509fn merge_kind(slot: &mut Option<HelperArgumentKind>, kind: HelperArgumentKind) -> bool {
512 match (*slot, kind) {
513 (None, _) => {
514 *slot = Some(kind);
515 true
516 }
517 (Some(HelperArgumentKind::GenericText), _) => false,
518 (Some(existing), _) if existing == kind => false,
519 (Some(_), HelperArgumentKind::GenericText) => {
520 *slot = Some(HelperArgumentKind::GenericText);
521 true
522 }
523 (Some(_), _) => false,
524 }
525}
526
527#[derive(Debug, Clone, Copy, PartialEq, Eq)]
528enum PredicateNameClass {
529 Uppercase,
530 Lowercase,
531 Other,
532}
533
534fn predicate_name_class(name: &str) -> PredicateNameClass {
535 if is_record_reference_variable_name(name) {
536 PredicateNameClass::Uppercase
537 } else if is_text_variable_name(name) {
538 PredicateNameClass::Lowercase
539 } else {
540 PredicateNameClass::Other
541 }
542}
543
544fn validate_head_predicate(name: &str, arity: usize) -> Result<(), String> {
545 let Some(predicate) = profile_predicate(name, arity)? else {
546 return Ok(());
547 };
548 if predicate.role != Role::Facet {
549 return Err(format!(
550 "derived rule shadows profile predicate {name}/{arity}"
551 ));
552 }
553 Ok(())
554}
555
556fn validate_body_predicate(name: &str, arity: usize) -> Result<(), String> {
558 match profile_predicate(name, arity)? {
559 Some(predicate) if predicate.role == Role::Facet => Err(format!(
560 "rule bodies must not reference output facet {name}/{arity}"
561 )),
562 _ => Ok(()),
563 }
564}
565
566fn profile_predicate(
569 name: &str,
570 arity: usize,
571) -> Result<Option<&'static ProfilePredicate>, String> {
572 if is_reserved_logic_word(name) {
573 return Err(format!(
574 "`{name}` is reserved logic syntax, not a predicate"
575 ));
576 }
577 match predicate_name_class(name) {
578 PredicateNameClass::Uppercase => {
579 let Some(predicate) = profile::lookup(name) else {
580 return Err(format!(
581 "unknown capitalized profile predicate {name}/{arity}; capitalized names are reserved for the profile"
582 ));
583 };
584 if predicate.arity() != arity {
585 return Err(format!(
586 "{name} expects arity {}, got {arity}",
587 predicate.arity()
588 ));
589 }
590 Ok(Some(predicate))
591 }
592 PredicateNameClass::Lowercase => Ok(None),
593 PredicateNameClass::Other => Err(format!("invalid predicate name {name}/{arity}")),
594 }
595}
596
597fn predicate_allows_cardinality(name: &str, arity: usize) -> bool {
598 match profile::lookup(name) {
599 Some(predicate) => {
600 predicate.arity() == arity && matches!(predicate.role, Role::Base { .. })
601 }
602 None => predicate_name_class(name) == PredicateNameClass::Lowercase,
603 }
604}
605
606fn predicate_allows_topk(name: &str, arity: usize) -> bool {
607 predicate_allows_cardinality(name, arity)
608}
609
610fn standard_predicate_mark(name: &str, arity: usize, terms: &[Term]) -> Option<Vec<KindPosition>> {
611 let predicate = profile::lookup(name).filter(|predicate| predicate.arity() == arity)?;
612 Some(
613 predicate
614 .kinds
615 .iter()
616 .map(|kind| match kind {
617 ArgKind::Record => KindPosition::Record,
618 ArgKind::RecordReference => KindPosition::RecordReference,
619 ArgKind::Text => KindPosition::TextOnly,
620 ArgKind::GenericText => KindPosition::GenericText,
621 ArgKind::FieldValue => match terms.get(1).and_then(Term::as_const) {
622 Some(":Type" | ":Length" | "Data-Length" | "By" | "Mark") => {
623 KindPosition::TextOnly
624 }
625 _ => KindPosition::GenericText,
626 },
627 })
628 .collect(),
629 )
630}
631
632fn validate_topk_partition(partition: &[Variable], terms: &[Term]) -> Result<(), String> {
633 let mut seen = HashSet::new();
634 for variable in partition {
635 if !seen.insert(variable) {
636 return Err("TopK PartitionBy variables must be distinct".into());
637 }
638 if !terms
639 .iter()
640 .any(|term| term == &Term::Var(variable.clone()))
641 {
642 return Err("TopK PartitionBy variable must occur in the candidate atom".into());
643 }
644 }
645 Ok(())
646}
647
648fn collect_positive_nonbuiltin_vars(rule: &Rule) -> HashSet<Variable> {
649 let mut vars = HashSet::new();
650 for atom in &rule.body {
651 match atom {
652 BodyAtom::Positive { terms, .. } | BodyAtom::TopK { terms, .. } => {
653 vars.extend(collect_vars(terms));
654 }
655 _ => {}
656 }
657 }
658 vars
659}
660
661fn collect_head_vars(rule: &Rule) -> HashSet<Variable> {
662 collect_vars(&rule.head_terms)
663}
664
665fn collect_vars(terms: &[Term]) -> HashSet<Variable> {
666 let mut vars = HashSet::new();
667 for t in terms {
668 if let Term::Var(v) = t {
669 vars.insert(v.clone());
670 }
671 }
672 vars
673}
674
675fn collect_terms_vars(terms: &[Term]) -> HashSet<Variable> {
676 collect_vars(terms)
677}