rust_fuzzylogic/
variable.rs

1// Variable: crisp scalar with domain and named fuzzy terms.
2// This file defines the `Variable` type plus red tests for its API.
3use crate::{error::FuzzyError, membership::MembershipFn, term::Term, Float};
4
5use std::collections::HashMap;
6
7/// A crisp variable with an inclusive numeric domain and a set of named terms.
8pub struct Variable {
9    /// Inclusive lower bound of the variable's domain.
10    min: Float,
11
12    /// Inclusive upper bound of the variable's domain.
13    max: Float,
14
15    /// Mapping from term name to its labeled membership function wrapper.
16    pub terms: HashMap<String, Term>,
17}
18impl Variable {
19    /// Constructs a new variable, validating that `min < max`.
20    pub fn new(min: Float, max: Float) -> crate::error::Result<Self> {
21        // Domain validation: bounds must be strictly ordered.
22        if min >= max {
23            Err(FuzzyError::OutOfBounds)
24        } else {
25            // Initialize with an empty term map.
26            Ok(Self {
27                min: min,
28                max: max,
29                terms: HashMap::new(),
30            })
31        }
32    }
33
34    /// Inserts a named term; rejects empty names and duplicates.
35    ///
36    /// - Empty name -> `FuzzyError::EmptyInput`
37    /// - Duplicate name -> `FuzzyError::TypeMismatch`
38    pub fn insert_term(&mut self, name: &str, t: Term) -> crate::error::Result<()> {
39        // Reject empty label.
40        if name == "" {
41            Err(FuzzyError::EmptyInput)
42        }
43        // Reject duplicates to avoid silent overwrites.
44        else if self.get(name).is_some() {
45            Err(FuzzyError::TypeMismatch)
46        } else {
47            // Store the term by name.
48            self.terms.insert(name.to_string(), t);
49            Ok(())
50        }
51    }
52
53    /// Returns a reference to the term for `name`, if present.
54    pub fn get(&self, name: &str) -> Option<&Term> {
55        self.terms.get(name)
56    }
57
58    /// Evaluates the membership degree for term `name` at input `x`.
59    ///
60    /// - Unknown term -> `FuzzyError::TypeMismatch`
61    /// - `x` out of `[min, max]` -> `FuzzyError::OutOfBounds`
62    pub fn eval(&self, name: &str, x: Float) -> crate::error::Result<Float> {
63        // Resolve term by name.
64        let v = &self.terms.get(name).ok_or(FuzzyError::TypeMismatch)?;
65        // Domain check is inclusive: allow x == min or x == max.
66        if self.max < x || self.min > x {
67            Err(FuzzyError::OutOfBounds)
68        }
69        // Delegate to the term's membership function.
70        else {
71            Ok(v.eval(x))
72        }
73    }
74
75    /// Returns the range of domain for the membership function.(term)
76    ///
77    /// - retunrs `(min, max)`
78    pub fn domain(&self) -> (Float, Float) {
79        (self.min, self.max)
80    }
81    //Optional helpers:
82    //pub fn names(&self) -> impl Iterator<Item=&str>
83    //pub fn fuzzify(&self, x: Float) -> crate::error::Result<Vec<(String, Float)>> to get all memberships at x.
84}
85
86/// Unit tests describing the expected `Variable` API and behavior.
87#[cfg(test)]
88mod tests {
89    use crate::error::FuzzyError;
90    use crate::membership::triangular::Triangular;
91    use crate::prelude::*;
92    use crate::term::Term;
93
94    /// `new` must reject invalid domain bounds (min >= max).
95    #[test]
96    fn test_new_rejects_invalid_domain() {
97        assert!(matches!(
98            crate::variable::Variable::new(1.0, 1.0),
99            Err(FuzzyError::OutOfBounds)
100        ));
101        assert!(matches!(
102            crate::variable::Variable::new(2.0, 1.0),
103            Err(FuzzyError::OutOfBounds)
104        ));
105    }
106
107    /// Insert two terms and evaluate memberships by name within the domain.
108    #[test]
109    fn test_insert_and_eval_by_name() {
110        let mut v = crate::variable::Variable::new(-10.0, 10.0).unwrap();
111
112        // Define two terms backed by triangular membership functions.
113        let cold_tri = Triangular::new(-10.0, -5.0, 0.0).unwrap();
114        let hot_tri = Triangular::new(0.0, 5.0, 10.0).unwrap();
115        let cold = Term::new("cold", cold_tri);
116        let hot = Term::new("hot", hot_tri);
117
118        // Insert terms and verify lookup works.
119        v.insert_term("cold", cold).unwrap();
120        v.insert_term("hot", hot).unwrap();
121
122        assert!(v.get("cold").is_some());
123        assert!(v.get("warm").is_none());
124
125        // Evaluate by name at a few in-domain points and compare to direct membership.
126        let x1 = -5.0;
127        let x2 = 7.5;
128
129        let expected_cold_x1 = Triangular::new(-10.0, -5.0, 0.0).unwrap().eval(x1);
130        let expected_hot_x2 = Triangular::new(0.0, 5.0, 10.0).unwrap().eval(x2);
131
132        let eps = crate::Float::EPSILON;
133        let y_cold_x1 = v.eval("cold", x1).unwrap();
134        let y_hot_x2 = v.eval("hot", x2).unwrap();
135        assert!((y_cold_x1 - expected_cold_x1).abs() < eps);
136        assert!((y_hot_x2 - expected_hot_x2).abs() < eps);
137
138        // Endpoints are considered in-domain: [min, max]
139        assert!(v.eval("cold", -10.0).is_ok());
140        assert!(v.eval("hot", 10.0).is_ok());
141    }
142
143    /// Reject duplicate term insertions for the same name.
144    #[test]
145    fn test_duplicate_term_rejected() {
146        let mut v = crate::variable::Variable::new(0.0, 1.0).unwrap();
147        let t1 = Term::new("x", Triangular::new(0.0, 0.5, 1.0).unwrap());
148        let t2 = Term::new("x", Triangular::new(0.0, 0.25, 0.5).unwrap());
149
150        v.insert_term("x", t1).unwrap();
151        // Second insertion with the same name should error (reject duplicates).
152        assert!(matches!(
153            v.insert_term("x", t2),
154            Err(FuzzyError::TypeMismatch)
155        ));
156    }
157
158    /// Unknown term lookup during eval should return an error.
159    #[test]
160    fn test_eval_unknown_term_errors() {
161        let v = crate::variable::Variable::new(0.0, 1.0).unwrap();
162        // Unknown term name: return a consistent error variant.
163        assert!(matches!(
164            v.eval("missing", 0.3),
165            Err(FuzzyError::TypeMismatch)
166        ));
167    }
168
169    /// Evaluating outside the variable domain should return OutOfBounds.
170    #[test]
171    fn test_eval_out_of_domain_errors() {
172        let mut v = crate::variable::Variable::new(0.0, 1.0).unwrap();
173        v.insert_term("x", Term::new("x", Triangular::new(0.0, 0.5, 1.0).unwrap()))
174            .unwrap();
175
176        // Out-of-domain x should return OutOfBounds.
177        assert!(matches!(v.eval("x", -0.1), Err(FuzzyError::OutOfBounds)));
178        assert!(matches!(v.eval("x", 1.1), Err(FuzzyError::OutOfBounds)));
179    }
180}