Skip to main content

ipfrs_tensorlogic/
rule_validator.rs

1//! TensorLogic rule validator.
2//!
3//! Validates TensorLogic rules for structural correctness, safety, and
4//! resource bounds before they are added to the knowledge base.
5
6use std::collections::HashMap;
7
8// ---------------------------------------------------------------------------
9// Errors
10// ---------------------------------------------------------------------------
11
12/// Errors that can be produced during rule validation.
13#[derive(Clone, Debug, PartialEq)]
14pub enum ValidationError {
15    /// The rule head contains no terms.
16    EmptyHead,
17    /// A variable that appears in the rule head is not bound in any body term.
18    UnboundVariable { var_name: String },
19    /// The rule directly depends on itself (circular dependency).
20    CircularDependency { rule_id: u64 },
21    /// The number of body terms exceeds the configured maximum.
22    ExcessiveBodyLength { length: usize, max: usize },
23    /// An identical head signature is already registered in the knowledge base.
24    DuplicateHead { existing_id: u64 },
25    /// The rule weight is outside the allowed range `(0.0, 1.0]`.
26    InvalidWeight { weight: f64 },
27}
28
29// ---------------------------------------------------------------------------
30// Validation result
31// ---------------------------------------------------------------------------
32
33/// The outcome of validating a single rule.
34#[derive(Debug, Clone)]
35pub struct ValidationResult {
36    /// The identifier of the rule that was validated.
37    pub rule_id: u64,
38    /// All structural/safety errors found during validation.
39    pub errors: Vec<ValidationError>,
40    /// Non-fatal advisory messages produced during validation.
41    pub warnings: Vec<String>,
42}
43
44impl ValidationResult {
45    /// Returns `true` when no errors were produced.
46    #[inline]
47    pub fn is_valid(&self) -> bool {
48        self.errors.is_empty()
49    }
50
51    /// Returns the number of errors collected.
52    #[inline]
53    pub fn error_count(&self) -> usize {
54        self.errors.len()
55    }
56}
57
58// ---------------------------------------------------------------------------
59// Validator configuration
60// ---------------------------------------------------------------------------
61
62/// Configuration knobs for [`TensorRuleValidator`].
63#[derive(Debug, Clone)]
64pub struct ValidatorConfig {
65    /// Maximum number of body terms allowed per rule.  Default: `64`.
66    pub max_body_length: usize,
67    /// When `false` (default) a rule whose head signature matches an already-
68    /// registered rule is rejected with [`ValidationError::DuplicateHead`].
69    pub allow_duplicate_heads: bool,
70    /// When `true` (default) the rule weight must be in `(0.0, 1.0]`.
71    pub require_positive_weight: bool,
72}
73
74impl Default for ValidatorConfig {
75    fn default() -> Self {
76        Self {
77            max_body_length: 64,
78            allow_duplicate_heads: false,
79            require_positive_weight: true,
80        }
81    }
82}
83
84// ---------------------------------------------------------------------------
85// Rule specification
86// ---------------------------------------------------------------------------
87
88/// A rule to be validated.
89#[derive(Debug, Clone)]
90pub struct RuleSpec {
91    /// Unique identifier of the rule.
92    pub rule_id: u64,
93    /// Terms that form the rule head (conclusion).
94    pub head_terms: Vec<String>,
95    /// Terms that form the rule body (premises).
96    pub body_terms: Vec<String>,
97    /// Variables that appear in the head — every one must occur as a substring
98    /// in at least one body term.
99    pub variables: Vec<String>,
100    /// Confidence / probability weight of the rule.
101    pub weight: f64,
102    /// IDs of other rules this rule depends on (used for circular-dependency
103    /// detection).
104    pub depends_on: Vec<u64>,
105}
106
107// ---------------------------------------------------------------------------
108// Validator
109// ---------------------------------------------------------------------------
110
111/// Validates [`RuleSpec`] instances before they are committed to the
112/// knowledge base.
113pub struct TensorRuleValidator {
114    /// Validation policy settings.
115    pub config: ValidatorConfig,
116    /// Maps a head signature (`head_terms` joined with `"|"`) to the
117    /// `rule_id` of the rule that first registered it.
118    pub registered_heads: HashMap<String, u64>,
119}
120
121impl TensorRuleValidator {
122    /// Creates a new validator with the supplied configuration.
123    pub fn new(config: ValidatorConfig) -> Self {
124        Self {
125            config,
126            registered_heads: HashMap::new(),
127        }
128    }
129
130    /// Validates `spec` and returns a [`ValidationResult`] describing every
131    /// error and warning found.  When the result contains no errors the head
132    /// signature is automatically registered so that subsequent duplicate
133    /// checks can detect it.
134    pub fn validate(&mut self, spec: &RuleSpec) -> ValidationResult {
135        let mut errors: Vec<ValidationError> = Vec::new();
136        let mut warnings: Vec<String> = Vec::new();
137
138        // ── 1. Empty head ────────────────────────────────────────────────────
139        if spec.head_terms.is_empty() {
140            errors.push(ValidationError::EmptyHead);
141        }
142
143        // ── 2. Unbound variables ─────────────────────────────────────────────
144        for var in &spec.variables {
145            let bound = spec
146                .body_terms
147                .iter()
148                .any(|body_term| body_term.contains(var.as_str()));
149            if !bound {
150                errors.push(ValidationError::UnboundVariable {
151                    var_name: var.clone(),
152                });
153            }
154        }
155
156        // ── 3. Circular dependency ───────────────────────────────────────────
157        if spec.depends_on.contains(&spec.rule_id) {
158            errors.push(ValidationError::CircularDependency {
159                rule_id: spec.rule_id,
160            });
161        }
162
163        // ── 4. Excessive body length ─────────────────────────────────────────
164        if spec.body_terms.len() > self.config.max_body_length {
165            errors.push(ValidationError::ExcessiveBodyLength {
166                length: spec.body_terms.len(),
167                max: self.config.max_body_length,
168            });
169        }
170
171        // ── 5. Duplicate head ────────────────────────────────────────────────
172        if !self.config.allow_duplicate_heads {
173            let signature = spec.head_terms.join("|");
174            if let Some(&existing_id) = self.registered_heads.get(&signature) {
175                errors.push(ValidationError::DuplicateHead { existing_id });
176            }
177        }
178
179        // ── 6. Invalid weight ────────────────────────────────────────────────
180        if self.config.require_positive_weight && (spec.weight <= 0.0 || spec.weight > 1.0) {
181            errors.push(ValidationError::InvalidWeight {
182                weight: spec.weight,
183            });
184        }
185
186        // ── 7. Fact-assertion warning ────────────────────────────────────────
187        if spec.body_terms.is_empty() && !spec.head_terms.is_empty() {
188            warnings.push("Rule has no body terms (fact assertion)".to_string());
189        }
190
191        // ── 8. Register on success ───────────────────────────────────────────
192        if errors.is_empty() {
193            let signature = spec.head_terms.join("|");
194            self.registered_heads.insert(signature, spec.rule_id);
195        }
196
197        ValidationResult {
198            rule_id: spec.rule_id,
199            errors,
200            warnings,
201        }
202    }
203
204    /// Returns how many valid rules have been registered.
205    pub fn registered_count(&self) -> usize {
206        self.registered_heads.len()
207    }
208
209    /// Removes all previously registered head signatures.
210    pub fn clear_registry(&mut self) {
211        self.registered_heads.clear();
212    }
213}
214
215// ---------------------------------------------------------------------------
216// Tests
217// ---------------------------------------------------------------------------
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    // ── Helpers ──────────────────────────────────────────────────────────────
224
225    fn default_validator() -> TensorRuleValidator {
226        TensorRuleValidator::new(ValidatorConfig::default())
227    }
228
229    /// Returns a minimal valid rule that passes every check.
230    fn valid_spec() -> RuleSpec {
231        RuleSpec {
232            rule_id: 1,
233            head_terms: vec!["parent(X, Y)".to_string()],
234            body_terms: vec!["father(X, Y)".to_string()],
235            variables: vec!["X".to_string(), "Y".to_string()],
236            weight: 1.0,
237            depends_on: vec![],
238        }
239    }
240
241    // ── 1. Valid rule passes ─────────────────────────────────────────────────
242    #[test]
243    fn test_valid_rule_passes() {
244        let mut v = default_validator();
245        let result = v.validate(&valid_spec());
246        assert!(result.is_valid());
247        assert_eq!(result.error_count(), 0);
248    }
249
250    // ── 2. EmptyHead detected ────────────────────────────────────────────────
251    #[test]
252    fn test_empty_head_detected() {
253        let mut v = default_validator();
254        let spec = RuleSpec {
255            head_terms: vec![],
256            ..valid_spec()
257        };
258        let result = v.validate(&spec);
259        assert!(!result.is_valid());
260        assert!(result.errors.contains(&ValidationError::EmptyHead));
261    }
262
263    // ── 3. UnboundVariable detected ──────────────────────────────────────────
264    #[test]
265    fn test_unbound_variable_detected() {
266        let mut v = default_validator();
267        let spec = RuleSpec {
268            variables: vec!["Z".to_string()],
269            body_terms: vec!["father(X, Y)".to_string()],
270            ..valid_spec()
271        };
272        let result = v.validate(&spec);
273        assert!(!result.is_valid());
274        assert!(result.errors.contains(&ValidationError::UnboundVariable {
275            var_name: "Z".to_string()
276        }));
277    }
278
279    // ── 4. Bound variable passes ─────────────────────────────────────────────
280    #[test]
281    fn test_bound_variable_passes() {
282        let mut v = default_validator();
283        let spec = RuleSpec {
284            variables: vec!["X".to_string()],
285            body_terms: vec!["node(X)".to_string()],
286            ..valid_spec()
287        };
288        let result = v.validate(&spec);
289        assert!(result.is_valid());
290    }
291
292    // ── 5. CircularDependency detected ───────────────────────────────────────
293    #[test]
294    fn test_circular_dependency_detected() {
295        let mut v = default_validator();
296        let spec = RuleSpec {
297            depends_on: vec![1],
298            ..valid_spec()
299        };
300        let result = v.validate(&spec);
301        assert!(!result.is_valid());
302        assert!(result
303            .errors
304            .contains(&ValidationError::CircularDependency { rule_id: 1 }));
305    }
306
307    // ── 6. ExcessiveBodyLength at limit + 1 ──────────────────────────────────
308    #[test]
309    fn test_excessive_body_length_detected() {
310        let mut v = default_validator();
311        let body: Vec<String> = (0..65).map(|i| format!("term_{i}(X)")).collect();
312        let spec = RuleSpec {
313            body_terms: body,
314            variables: vec!["X".to_string()],
315            ..valid_spec()
316        };
317        let result = v.validate(&spec);
318        assert!(!result.is_valid());
319        assert!(result.errors.iter().any(|e| matches!(
320            e,
321            ValidationError::ExcessiveBodyLength {
322                length: 65,
323                max: 64
324            }
325        )));
326    }
327
328    // ── 7. ExcessiveBodyLength passes at exactly the limit ───────────────────
329    #[test]
330    fn test_body_length_at_limit_passes() {
331        let mut v = default_validator();
332        let body: Vec<String> = (0..64).map(|i| format!("term_{i}(X)")).collect();
333        let spec = RuleSpec {
334            body_terms: body,
335            variables: vec!["X".to_string()],
336            ..valid_spec()
337        };
338        let result = v.validate(&spec);
339        assert!(result.is_valid());
340    }
341
342    // ── 8. DuplicateHead when allow_duplicate_heads = false ──────────────────
343    #[test]
344    fn test_duplicate_head_detected() {
345        let mut v = default_validator();
346        let spec = valid_spec();
347        let first = v.validate(&spec);
348        assert!(first.is_valid());
349
350        let spec2 = RuleSpec {
351            rule_id: 2,
352            ..valid_spec()
353        };
354        let second = v.validate(&spec2);
355        assert!(!second.is_valid());
356        assert!(second
357            .errors
358            .contains(&ValidationError::DuplicateHead { existing_id: 1 }));
359    }
360
361    // ── 9. DuplicateHead allowed when allow_duplicate_heads = true ───────────
362    #[test]
363    fn test_duplicate_head_allowed() {
364        let config = ValidatorConfig {
365            allow_duplicate_heads: true,
366            ..Default::default()
367        };
368        let mut v = TensorRuleValidator::new(config);
369        let spec = valid_spec();
370        let first = v.validate(&spec);
371        assert!(first.is_valid());
372
373        let spec2 = RuleSpec {
374            rule_id: 2,
375            ..valid_spec()
376        };
377        let second = v.validate(&spec2);
378        assert!(second.is_valid());
379    }
380
381    // ── 10. InvalidWeight at 0.0 ─────────────────────────────────────────────
382    #[test]
383    fn test_invalid_weight_zero() {
384        let mut v = default_validator();
385        let spec = RuleSpec {
386            weight: 0.0,
387            ..valid_spec()
388        };
389        let result = v.validate(&spec);
390        assert!(!result.is_valid());
391        assert!(result
392            .errors
393            .contains(&ValidationError::InvalidWeight { weight: 0.0 }));
394    }
395
396    // ── 11. InvalidWeight at negative ────────────────────────────────────────
397    #[test]
398    fn test_invalid_weight_negative() {
399        let mut v = default_validator();
400        let spec = RuleSpec {
401            weight: -0.5,
402            ..valid_spec()
403        };
404        let result = v.validate(&spec);
405        assert!(!result.is_valid());
406        assert!(result
407            .errors
408            .contains(&ValidationError::InvalidWeight { weight: -0.5 }));
409    }
410
411    // ── 12. InvalidWeight at 1.01 ────────────────────────────────────────────
412    #[test]
413    fn test_invalid_weight_above_one() {
414        let mut v = default_validator();
415        let spec = RuleSpec {
416            weight: 1.01,
417            ..valid_spec()
418        };
419        let result = v.validate(&spec);
420        assert!(!result.is_valid());
421        assert!(result
422            .errors
423            .iter()
424            .any(|e| matches!(e, ValidationError::InvalidWeight { .. })));
425    }
426
427    // ── 13. Weight at exactly 1.0 passes ────────────────────────────────────
428    #[test]
429    fn test_weight_exactly_one_passes() {
430        let mut v = default_validator();
431        let spec = RuleSpec {
432            weight: 1.0,
433            ..valid_spec()
434        };
435        let result = v.validate(&spec);
436        assert!(result.is_valid());
437    }
438
439    // ── 14. Weight at 0.5 passes ─────────────────────────────────────────────
440    #[test]
441    fn test_weight_point_five_passes() {
442        let mut v = default_validator();
443        let spec = RuleSpec {
444            weight: 0.5,
445            ..valid_spec()
446        };
447        let result = v.validate(&spec);
448        assert!(result.is_valid());
449    }
450
451    // ── 15. is_valid true when no errors ────────────────────────────────────
452    #[test]
453    fn test_is_valid_true_no_errors() {
454        let mut v = default_validator();
455        let result = v.validate(&valid_spec());
456        assert!(result.is_valid());
457        assert_eq!(result.error_count(), 0);
458    }
459
460    // ── 16. is_valid false when errors present ───────────────────────────────
461    #[test]
462    fn test_is_valid_false_with_errors() {
463        let mut v = default_validator();
464        let spec = RuleSpec {
465            head_terms: vec![],
466            ..valid_spec()
467        };
468        let result = v.validate(&spec);
469        assert!(!result.is_valid());
470        assert!(result.error_count() > 0);
471    }
472
473    // ── 17. Warning for fact assertion (empty body) ──────────────────────────
474    #[test]
475    fn test_warning_for_fact_assertion() {
476        let mut v = default_validator();
477        let spec = RuleSpec {
478            body_terms: vec![],
479            variables: vec![],
480            ..valid_spec()
481        };
482        let result = v.validate(&spec);
483        assert!(result.is_valid());
484        assert!(result.warnings.iter().any(|w| w.contains("fact assertion")));
485    }
486
487    // ── 18. registered_count increments on valid rules ───────────────────────
488    #[test]
489    fn test_registered_count_increments() {
490        let mut v = default_validator();
491        assert_eq!(v.registered_count(), 0);
492
493        let spec1 = valid_spec();
494        v.validate(&spec1);
495        assert_eq!(v.registered_count(), 1);
496
497        let spec2 = RuleSpec {
498            rule_id: 2,
499            head_terms: vec!["sibling(X, Y)".to_string()],
500            body_terms: vec!["parent(Z, X)".to_string(), "parent(Z, Y)".to_string()],
501            variables: vec!["X".to_string(), "Y".to_string(), "Z".to_string()],
502            weight: 0.9,
503            depends_on: vec![],
504        };
505        v.validate(&spec2);
506        assert_eq!(v.registered_count(), 2);
507    }
508
509    // ── 19. clear_registry resets registered_count ──────────────────────────
510    #[test]
511    fn test_clear_registry_resets_count() {
512        let mut v = default_validator();
513        v.validate(&valid_spec());
514        assert_eq!(v.registered_count(), 1);
515        v.clear_registry();
516        assert_eq!(v.registered_count(), 0);
517    }
518
519    // ── 20. Multiple errors accumulated ─────────────────────────────────────
520    #[test]
521    fn test_multiple_errors_accumulated() {
522        let mut v = default_validator();
523        // Trigger EmptyHead + CircularDependency + InvalidWeight simultaneously.
524        let spec = RuleSpec {
525            rule_id: 42,
526            head_terms: vec![], // → EmptyHead
527            body_terms: vec![],
528            variables: vec![],
529            weight: 0.0,          // → InvalidWeight
530            depends_on: vec![42], // → CircularDependency
531        };
532        let result = v.validate(&spec);
533        assert!(!result.is_valid());
534        assert!(result.errors.contains(&ValidationError::EmptyHead));
535        assert!(result
536            .errors
537            .contains(&ValidationError::CircularDependency { rule_id: 42 }));
538        assert!(result
539            .errors
540            .contains(&ValidationError::InvalidWeight { weight: 0.0 }));
541        assert!(result.error_count() >= 3);
542    }
543
544    // ── 21. Invalid rule does not register head ──────────────────────────────
545    #[test]
546    fn test_invalid_rule_not_registered() {
547        let mut v = default_validator();
548        let spec = RuleSpec {
549            weight: 0.0, // invalid
550            ..valid_spec()
551        };
552        v.validate(&spec);
553        assert_eq!(v.registered_count(), 0);
554    }
555
556    // ── 22. Non-self circular dependency does NOT trigger error ──────────────
557    #[test]
558    fn test_non_self_depends_on_ok() {
559        let mut v = default_validator();
560        let spec = RuleSpec {
561            depends_on: vec![99, 100],
562            ..valid_spec()
563        };
564        let result = v.validate(&spec);
565        assert!(result.is_valid());
566    }
567
568    // ── 23. require_positive_weight = false allows weight 0.0 ───────────────
569    #[test]
570    fn test_no_weight_requirement_allows_zero() {
571        let config = ValidatorConfig {
572            require_positive_weight: false,
573            ..Default::default()
574        };
575        let mut v = TensorRuleValidator::new(config);
576        let spec = RuleSpec {
577            weight: 0.0,
578            ..valid_spec()
579        };
580        let result = v.validate(&spec);
581        assert!(result.is_valid());
582    }
583
584    // ── 24. ValidationResult rule_id matches spec rule_id ───────────────────
585    #[test]
586    fn test_result_rule_id_matches() {
587        let mut v = default_validator();
588        let spec = valid_spec();
589        let result = v.validate(&spec);
590        assert_eq!(result.rule_id, spec.rule_id);
591    }
592}