Skip to main content

ipfrs_tensorlogic/
fuzzy_logic.rs

1//! Fuzzy Logic Engine for approximate reasoning.
2//!
3//! Provides membership functions, fuzzy variables, fuzzy rules, inference
4//! (Mamdani / Sugeno), and defuzzification (Centroid, MeanOfMax, LargestOfMax).
5
6use std::collections::HashMap;
7use thiserror::Error;
8
9// ---------------------------------------------------------------------------
10// Errors
11// ---------------------------------------------------------------------------
12
13/// Errors produced by [`FuzzyLogicEngine`] and related types.
14#[derive(Debug, Clone, Error)]
15pub enum FuzzyError {
16    /// A referenced variable does not exist in the engine.
17    #[error("fuzzy variable not found: {0}")]
18    VariableNotFound(String),
19
20    /// A referenced set does not exist inside a variable.
21    #[error("fuzzy set '{set}' not found in variable '{variable}'")]
22    SetNotFound { variable: String, set: String },
23
24    /// Attempt to add a variable that is already registered.
25    #[error("duplicate fuzzy variable: {0}")]
26    DuplicateVariable(String),
27
28    /// `infer` was called but no rule targets the requested output variable.
29    #[error("no fuzzy rules target output variable '{0}'")]
30    NoRulesForOutput(String),
31
32    /// A numerical issue prevented completing the computation (e.g. division by
33    /// zero in defuzzification when all membership degrees are zero).
34    #[error("numerical error in fuzzy inference: {0}")]
35    NumericalError(String),
36}
37
38// ---------------------------------------------------------------------------
39// Membership functions
40// ---------------------------------------------------------------------------
41
42/// A membership function that maps a crisp value `x` to a degree in `[0, 1]`.
43#[derive(Debug, Clone, PartialEq)]
44pub enum MembershipFunction {
45    /// Triangular MF — zero at `a` and `c`, peak 1.0 at `b`.
46    ///
47    /// `μ(x) = max(0, min((x-a)/(b-a), (c-x)/(c-b)))`
48    Triangular { a: f64, b: f64, c: f64 },
49
50    /// Trapezoidal MF — zero below `a` and above `d`, full membership on `[b,c]`.
51    ///
52    /// `μ(x) = max(0, min((x-a)/(b-a), 1.0, (d-x)/(d-c)))`
53    Trapezoidal { a: f64, b: f64, c: f64, d: f64 },
54
55    /// Gaussian MF — bell-shaped curve.
56    ///
57    /// `μ(x) = exp(-0.5 * ((x - mean) / sigma)^2)`
58    Gaussian { mean: f64, sigma: f64 },
59
60    /// Singleton MF — full membership at exactly one point, zero elsewhere.
61    Singleton { value: f64 },
62
63    /// Universe of discourse — full membership everywhere.
64    Universe,
65}
66
67impl MembershipFunction {
68    /// Evaluate the membership degree of `x`.
69    #[must_use]
70    pub fn evaluate(&self, x: f64) -> f64 {
71        match self {
72            MembershipFunction::Triangular { a, b, c } => {
73                let left = if (b - a).abs() < f64::EPSILON {
74                    if (x - a).abs() < f64::EPSILON {
75                        1.0
76                    } else {
77                        0.0
78                    }
79                } else {
80                    (x - a) / (b - a)
81                };
82                let right = if (c - b).abs() < f64::EPSILON {
83                    if (x - b).abs() < f64::EPSILON {
84                        1.0
85                    } else {
86                        0.0
87                    }
88                } else {
89                    (c - x) / (c - b)
90                };
91                left.min(right).max(0.0)
92            }
93            MembershipFunction::Trapezoidal { a, b, c, d } => {
94                let left = if (b - a).abs() < f64::EPSILON {
95                    if x >= *a {
96                        1.0
97                    } else {
98                        0.0
99                    }
100                } else {
101                    (x - a) / (b - a)
102                };
103                let right = if (d - c).abs() < f64::EPSILON {
104                    if x <= *d {
105                        1.0
106                    } else {
107                        0.0
108                    }
109                } else {
110                    (d - x) / (d - c)
111                };
112                left.min(1.0_f64).min(right).max(0.0)
113            }
114            MembershipFunction::Gaussian { mean, sigma } => {
115                if sigma.abs() < f64::EPSILON {
116                    if (x - mean).abs() < f64::EPSILON {
117                        1.0
118                    } else {
119                        0.0
120                    }
121                } else {
122                    let z = (x - mean) / sigma;
123                    (-0.5 * z * z).exp()
124                }
125            }
126            MembershipFunction::Singleton { value } => {
127                if (x - value).abs() < f64::EPSILON {
128                    1.0
129                } else {
130                    0.0
131                }
132            }
133            MembershipFunction::Universe => 1.0,
134        }
135    }
136}
137
138// ---------------------------------------------------------------------------
139// FuzzySet
140// ---------------------------------------------------------------------------
141
142/// A named fuzzy set with an associated membership function.
143#[derive(Debug, Clone)]
144pub struct FuzzySet {
145    /// Human-readable name (e.g. "low", "medium", "high").
146    pub name: String,
147    /// Membership function that defines the set.
148    pub mf: MembershipFunction,
149}
150
151impl FuzzySet {
152    /// Create a new fuzzy set.
153    #[must_use]
154    pub fn new(name: impl Into<String>, mf: MembershipFunction) -> Self {
155        Self {
156            name: name.into(),
157            mf,
158        }
159    }
160
161    /// Return the membership degree of `x` in this set.
162    #[must_use]
163    pub fn degree(&self, x: f64) -> f64 {
164        self.mf.evaluate(x)
165    }
166}
167
168// ---------------------------------------------------------------------------
169// FuzzyVariable
170// ---------------------------------------------------------------------------
171
172/// A linguistic variable defined over a numeric range `[min_val, max_val]`.
173#[derive(Debug, Clone)]
174pub struct FuzzyVariable {
175    /// Name of the variable (e.g. "temperature").
176    pub name: String,
177    /// The named fuzzy sets that partition this variable's universe.
178    pub sets: Vec<FuzzySet>,
179    /// Lower bound of the universe of discourse.
180    pub min_val: f64,
181    /// Upper bound of the universe of discourse.
182    pub max_val: f64,
183}
184
185impl FuzzyVariable {
186    /// Create a new fuzzy variable.
187    #[must_use]
188    pub fn new(name: impl Into<String>, min_val: f64, max_val: f64) -> Self {
189        Self {
190            name: name.into(),
191            sets: Vec::new(),
192            min_val,
193            max_val,
194        }
195    }
196
197    /// Add a fuzzy set to this variable.
198    pub fn add_set(&mut self, set: FuzzySet) {
199        self.sets.push(set);
200    }
201
202    /// Look up a set by name.
203    #[must_use]
204    pub fn get_set(&self, name: &str) -> Option<&FuzzySet> {
205        self.sets.iter().find(|s| s.name == name)
206    }
207}
208
209// ---------------------------------------------------------------------------
210// FuzzyProposition
211// ---------------------------------------------------------------------------
212
213/// An atomic fuzzy proposition: "*variable* IS *set*".
214#[derive(Debug, Clone)]
215pub struct FuzzyProposition {
216    /// Name of the linguistic variable.
217    pub variable: String,
218    /// Name of the fuzzy set.
219    pub set: String,
220}
221
222impl FuzzyProposition {
223    /// Create a new proposition.
224    #[must_use]
225    pub fn new(variable: impl Into<String>, set: impl Into<String>) -> Self {
226        Self {
227            variable: variable.into(),
228            set: set.into(),
229        }
230    }
231}
232
233// ---------------------------------------------------------------------------
234// FuzzyRule
235// ---------------------------------------------------------------------------
236
237/// A fuzzy IF-THEN rule.
238///
239/// Multiple antecedents are combined with AND (minimum T-norm).
240/// The overall activation is multiplied by `weight` before being applied
241/// to the consequent.
242#[derive(Debug, Clone)]
243pub struct FuzzyRule {
244    /// Antecedent propositions (all combined with AND / minimum).
245    pub antecedents: Vec<FuzzyProposition>,
246    /// The consequent proposition (output linguistic variable and set).
247    pub consequent: FuzzyProposition,
248    /// Rule importance weight in `[0, 1]`.
249    pub weight: f64,
250}
251
252impl FuzzyRule {
253    /// Convenience constructor.
254    #[must_use]
255    pub fn new(
256        antecedents: Vec<FuzzyProposition>,
257        consequent: FuzzyProposition,
258        weight: f64,
259    ) -> Self {
260        Self {
261            antecedents,
262            consequent,
263            weight,
264        }
265    }
266}
267
268// ---------------------------------------------------------------------------
269// Inference and defuzzification method enums
270// ---------------------------------------------------------------------------
271
272/// Fuzzy inference strategy.
273#[derive(Debug, Clone, Copy, PartialEq, Eq)]
274pub enum InferenceMethod {
275    /// Mamdani min-of-max inference with aggregation and defuzzification.
276    Mamdani,
277    /// Takagi–Sugeno–Kang (TSK / Sugeno) weighted centroid aggregation.
278    Sugeno,
279}
280
281/// Defuzzification method (used with Mamdani inference).
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub enum DefuzzMethod {
284    /// Centre-of-gravity (centroid) numerical integration.
285    Centroid,
286    /// Mean of the x-values where the aggregate membership is maximal.
287    MeanOfMax,
288    /// Rightmost x where the aggregate membership is maximal.
289    LargestOfMax,
290}
291
292// ---------------------------------------------------------------------------
293// FuzzyStats
294// ---------------------------------------------------------------------------
295
296/// Snapshot of engine configuration statistics.
297#[derive(Debug, Clone)]
298pub struct FuzzyStats {
299    /// Number of registered linguistic variables.
300    pub variable_count: usize,
301    /// Number of registered rules.
302    pub rule_count: usize,
303    /// Name of the inference method in use.
304    pub inference: String,
305    /// Name of the defuzzification method in use.
306    pub defuzz: String,
307}
308
309// ---------------------------------------------------------------------------
310// FuzzyLogicEngine
311// ---------------------------------------------------------------------------
312
313/// Number of integration steps used in centroid defuzzification.
314const CENTROID_STEPS: usize = 100;
315
316/// A complete fuzzy logic inference system.
317///
318/// # Usage
319///
320/// 1. Create an engine with [`FuzzyLogicEngine::new`].
321/// 2. Register linguistic variables with [`FuzzyLogicEngine::add_variable`].
322/// 3. Add IF-THEN rules with [`FuzzyLogicEngine::add_rule`].
323/// 4. Call [`FuzzyLogicEngine::infer`] or [`FuzzyLogicEngine::evaluate`] to
324///    obtain a crisp output for a given set of crisp inputs.
325pub struct FuzzyLogicEngine {
326    variables: HashMap<String, FuzzyVariable>,
327    rules: Vec<FuzzyRule>,
328    inference: InferenceMethod,
329    defuzz: DefuzzMethod,
330}
331
332impl FuzzyLogicEngine {
333    // ------------------------------------------------------------------
334    // Construction
335    // ------------------------------------------------------------------
336
337    /// Create a new engine with the given inference and defuzzification strategies.
338    #[must_use]
339    pub fn new(inference: InferenceMethod, defuzz: DefuzzMethod) -> Self {
340        Self {
341            variables: HashMap::new(),
342            rules: Vec::new(),
343            inference,
344            defuzz,
345        }
346    }
347
348    // ------------------------------------------------------------------
349    // Variable and rule registration
350    // ------------------------------------------------------------------
351
352    /// Register a linguistic variable.
353    ///
354    /// # Errors
355    /// Returns [`FuzzyError::DuplicateVariable`] if a variable with the same
356    /// name already exists.
357    pub fn add_variable(&mut self, var: FuzzyVariable) -> Result<(), FuzzyError> {
358        if self.variables.contains_key(&var.name) {
359            return Err(FuzzyError::DuplicateVariable(var.name.clone()));
360        }
361        self.variables.insert(var.name.clone(), var);
362        Ok(())
363    }
364
365    /// Register a fuzzy rule.
366    ///
367    /// Validates that every variable and set referenced by the rule exists.
368    ///
369    /// # Errors
370    /// Returns [`FuzzyError::VariableNotFound`] or [`FuzzyError::SetNotFound`]
371    /// if any reference is invalid.
372    pub fn add_rule(&mut self, rule: FuzzyRule) -> Result<(), FuzzyError> {
373        // Validate antecedents
374        for prop in &rule.antecedents {
375            self.validate_proposition(prop)?;
376        }
377        // Validate consequent
378        self.validate_proposition(&rule.consequent)?;
379        self.rules.push(rule);
380        Ok(())
381    }
382
383    // ------------------------------------------------------------------
384    // Query helpers
385    // ------------------------------------------------------------------
386
387    /// Return the number of registered rules.
388    #[must_use]
389    pub fn rule_count(&self) -> usize {
390        self.rules.len()
391    }
392
393    /// Return the names of all registered variables.
394    #[must_use]
395    pub fn variable_names(&self) -> Vec<&str> {
396        self.variables.keys().map(String::as_str).collect()
397    }
398
399    /// Return the membership degree of `x` in a specific set of a variable.
400    ///
401    /// # Errors
402    /// Returns [`FuzzyError::VariableNotFound`] or [`FuzzyError::SetNotFound`].
403    pub fn membership_at(&self, variable: &str, set: &str, x: f64) -> Result<f64, FuzzyError> {
404        let var = self.get_variable(variable)?;
405        let fuzzy_set = var.get_set(set).ok_or_else(|| FuzzyError::SetNotFound {
406            variable: variable.to_string(),
407            set: set.to_string(),
408        })?;
409        Ok(fuzzy_set.degree(x))
410    }
411
412    /// Fuzzify a crisp value for a named variable.
413    ///
414    /// Returns a vector of `(set_name, degree)` pairs for every set defined
415    /// on the variable.
416    ///
417    /// # Errors
418    /// Returns [`FuzzyError::VariableNotFound`] if the variable is unknown.
419    pub fn fuzzify(&self, variable: &str, crisp: f64) -> Result<Vec<(String, f64)>, FuzzyError> {
420        let var = self.get_variable(variable)?;
421        Ok(var
422            .sets
423            .iter()
424            .map(|s| (s.name.clone(), s.degree(crisp)))
425            .collect())
426    }
427
428    /// Evaluate the antecedent of `rule` given a map of crisp input values.
429    ///
430    /// Returns the weighted activation degree `α * rule.weight`.
431    ///
432    /// # Errors
433    /// Returns [`FuzzyError::VariableNotFound`] or [`FuzzyError::SetNotFound`].
434    pub fn fire_rule(
435        &self,
436        rule: &FuzzyRule,
437        inputs: &HashMap<String, f64>,
438    ) -> Result<f64, FuzzyError> {
439        let mut activation: f64 = 1.0;
440        for prop in &rule.antecedents {
441            let crisp = inputs.get(&prop.variable).copied().unwrap_or(0.0);
442            let degree = self.membership_at(&prop.variable, &prop.set, crisp)?;
443            activation = activation.min(degree);
444        }
445        Ok(activation * rule.weight)
446    }
447
448    /// Run the complete fuzzy inference pipeline and return a crisp output
449    /// for `output_var`.
450    ///
451    /// # Errors
452    /// * [`FuzzyError::VariableNotFound`] — `output_var` or any input variable is missing.
453    /// * [`FuzzyError::SetNotFound`] — a rule references an undefined set.
454    /// * [`FuzzyError::NoRulesForOutput`] — no rules target `output_var`.
455    /// * [`FuzzyError::NumericalError`] — all aggregate membership values are zero.
456    pub fn infer(
457        &self,
458        inputs: &HashMap<String, f64>,
459        output_var: &str,
460    ) -> Result<f64, FuzzyError> {
461        // Collect rules that address the requested output variable
462        let relevant: Vec<(&FuzzyRule, f64)> = self
463            .rules
464            .iter()
465            .filter(|r| r.consequent.variable == output_var)
466            .map(|r| {
467                let alpha = self.fire_rule(r, inputs)?;
468                Ok((r, alpha))
469            })
470            .collect::<Result<Vec<_>, FuzzyError>>()?;
471
472        if relevant.is_empty() {
473            return Err(FuzzyError::NoRulesForOutput(output_var.to_string()));
474        }
475
476        match self.inference {
477            InferenceMethod::Mamdani => self.infer_mamdani(output_var, &relevant),
478            InferenceMethod::Sugeno => self.infer_sugeno(output_var, &relevant),
479        }
480    }
481
482    /// Alias for [`infer`](FuzzyLogicEngine::infer); accepts `&mut self` for
483    /// future stateful extensions.
484    ///
485    /// # Errors
486    /// Same as [`infer`](FuzzyLogicEngine::infer).
487    pub fn evaluate(
488        &mut self,
489        inputs: &HashMap<String, f64>,
490        output_var: &str,
491    ) -> Result<f64, FuzzyError> {
492        self.infer(inputs, output_var)
493    }
494
495    /// Return a snapshot of engine statistics.
496    #[must_use]
497    pub fn stats(&self) -> FuzzyStats {
498        FuzzyStats {
499            variable_count: self.variables.len(),
500            rule_count: self.rules.len(),
501            inference: format!("{:?}", self.inference),
502            defuzz: format!("{:?}", self.defuzz),
503        }
504    }
505
506    // ------------------------------------------------------------------
507    // Private helpers
508    // ------------------------------------------------------------------
509
510    fn get_variable(&self, name: &str) -> Result<&FuzzyVariable, FuzzyError> {
511        self.variables
512            .get(name)
513            .ok_or_else(|| FuzzyError::VariableNotFound(name.to_string()))
514    }
515
516    fn validate_proposition(&self, prop: &FuzzyProposition) -> Result<(), FuzzyError> {
517        let var = self.get_variable(&prop.variable)?;
518        if var.get_set(&prop.set).is_none() {
519            return Err(FuzzyError::SetNotFound {
520                variable: prop.variable.clone(),
521                set: prop.set.clone(),
522            });
523        }
524        Ok(())
525    }
526
527    /// Mamdani inference: clip each consequent set at its activation α,
528    /// aggregate with pointwise maximum, then defuzzify.
529    fn infer_mamdani(
530        &self,
531        output_var: &str,
532        relevant: &[(&FuzzyRule, f64)],
533    ) -> Result<f64, FuzzyError> {
534        let out_var = self.get_variable(output_var)?;
535        let steps = CENTROID_STEPS;
536        let range = out_var.max_val - out_var.min_val;
537        let step_size = range / steps as f64;
538
539        // Build the aggregate membership function via pointwise max of clipped sets.
540        // We represent it as a sampled array over `steps` uniformly spaced points.
541        let x_values: Vec<f64> = (0..=steps)
542            .map(|i| out_var.min_val + i as f64 * step_size)
543            .collect();
544
545        let mut aggregate: Vec<f64> = vec![0.0; x_values.len()];
546        for (rule, alpha) in relevant {
547            let set =
548                out_var
549                    .get_set(&rule.consequent.set)
550                    .ok_or_else(|| FuzzyError::SetNotFound {
551                        variable: output_var.to_string(),
552                        set: rule.consequent.set.clone(),
553                    })?;
554            for (i, &x) in x_values.iter().enumerate() {
555                let mu = set.degree(x).min(*alpha);
556                if mu > aggregate[i] {
557                    aggregate[i] = mu;
558                }
559            }
560        }
561
562        self.defuzzify(&x_values, &aggregate)
563    }
564
565    /// Sugeno inference: weighted sum of set centroids divided by total weight.
566    fn infer_sugeno(
567        &self,
568        output_var: &str,
569        relevant: &[(&FuzzyRule, f64)],
570    ) -> Result<f64, FuzzyError> {
571        let out_var = self.get_variable(output_var)?;
572        let steps = CENTROID_STEPS;
573        let range = out_var.max_val - out_var.min_val;
574        let step_size = range / steps as f64;
575
576        let x_values: Vec<f64> = (0..=steps)
577            .map(|i| out_var.min_val + i as f64 * step_size)
578            .collect();
579
580        let mut weighted_sum: f64 = 0.0;
581        let mut weight_total: f64 = 0.0;
582
583        for (rule, alpha) in relevant {
584            if *alpha <= 0.0 {
585                continue;
586            }
587            let set =
588                out_var
589                    .get_set(&rule.consequent.set)
590                    .ok_or_else(|| FuzzyError::SetNotFound {
591                        variable: output_var.to_string(),
592                        set: rule.consequent.set.clone(),
593                    })?;
594            // Compute centroid of this set over the universe
595            let centroid = centroid_of_set(set, &x_values);
596            weighted_sum += alpha * centroid;
597            weight_total += alpha;
598        }
599
600        if weight_total < f64::EPSILON {
601            return Err(FuzzyError::NumericalError(
602                "Sugeno total activation weight is zero".to_string(),
603            ));
604        }
605        Ok(weighted_sum / weight_total)
606    }
607
608    /// Apply the configured defuzzification method to a sampled aggregate MF.
609    fn defuzzify(&self, x: &[f64], mu: &[f64]) -> Result<f64, FuzzyError> {
610        match self.defuzz {
611            DefuzzMethod::Centroid => defuzz_centroid(x, mu),
612            DefuzzMethod::MeanOfMax => defuzz_mean_of_max(x, mu),
613            DefuzzMethod::LargestOfMax => defuzz_largest_of_max(x, mu),
614        }
615    }
616}
617
618// ---------------------------------------------------------------------------
619// Stand-alone defuzzification helpers
620// ---------------------------------------------------------------------------
621
622/// Centre-of-gravity defuzzification.
623fn defuzz_centroid(x: &[f64], mu: &[f64]) -> Result<f64, FuzzyError> {
624    let sum_xmu: f64 = x.iter().zip(mu.iter()).map(|(xi, mi)| xi * mi).sum();
625    let sum_mu: f64 = mu.iter().sum();
626    if sum_mu < f64::EPSILON {
627        return Err(FuzzyError::NumericalError(
628            "centroid defuzzification: aggregate membership is all-zero".to_string(),
629        ));
630    }
631    Ok(sum_xmu / sum_mu)
632}
633
634/// Mean-of-maximum defuzzification — average of all x where μ is maximum.
635fn defuzz_mean_of_max(x: &[f64], mu: &[f64]) -> Result<f64, FuzzyError> {
636    let max_mu = mu.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
637    if max_mu < f64::EPSILON {
638        return Err(FuzzyError::NumericalError(
639            "mean-of-max defuzzification: aggregate membership is all-zero".to_string(),
640        ));
641    }
642    let max_x: Vec<f64> = x
643        .iter()
644        .zip(mu.iter())
645        .filter(|(_, &m)| (m - max_mu).abs() < 1e-10)
646        .map(|(&xi, _)| xi)
647        .collect();
648    if max_x.is_empty() {
649        return Err(FuzzyError::NumericalError(
650            "mean-of-max defuzzification: no maximum found".to_string(),
651        ));
652    }
653    Ok(max_x.iter().sum::<f64>() / max_x.len() as f64)
654}
655
656/// Largest-of-maximum defuzzification — rightmost x where μ is maximum.
657fn defuzz_largest_of_max(x: &[f64], mu: &[f64]) -> Result<f64, FuzzyError> {
658    let max_mu = mu.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
659    if max_mu < f64::EPSILON {
660        return Err(FuzzyError::NumericalError(
661            "largest-of-max defuzzification: aggregate membership is all-zero".to_string(),
662        ));
663    }
664    let largest_x = x
665        .iter()
666        .zip(mu.iter())
667        .filter(|(_, &m)| (m - max_mu).abs() < 1e-10)
668        .map(|(&xi, _)| xi)
669        .fold(f64::NEG_INFINITY, f64::max);
670    if largest_x == f64::NEG_INFINITY {
671        return Err(FuzzyError::NumericalError(
672            "largest-of-max defuzzification: no maximum found".to_string(),
673        ));
674    }
675    Ok(largest_x)
676}
677
678/// Compute the centroid of a fuzzy set sampled over `x_values`.
679fn centroid_of_set(set: &FuzzySet, x_values: &[f64]) -> f64 {
680    let sum_xmu: f64 = x_values.iter().map(|&x| x * set.degree(x)).sum();
681    let sum_mu: f64 = x_values.iter().map(|&x| set.degree(x)).sum();
682    if sum_mu < f64::EPSILON {
683        // Degenerate: return midpoint of first and last x
684        let first = x_values.first().copied().unwrap_or(0.0);
685        let last = x_values.last().copied().unwrap_or(0.0);
686        (first + last) / 2.0
687    } else {
688        sum_xmu / sum_mu
689    }
690}
691
692// ---------------------------------------------------------------------------
693// Tests
694// ---------------------------------------------------------------------------
695
696#[cfg(test)]
697mod tests {
698    use crate::fuzzy_logic::{
699        DefuzzMethod, FuzzyError, FuzzyLogicEngine, FuzzyProposition, FuzzyRule, FuzzySet,
700        FuzzyVariable, InferenceMethod, MembershipFunction,
701    };
702    use std::collections::HashMap;
703
704    // -----------------------------------------------------------------------
705    // MembershipFunction tests
706    // -----------------------------------------------------------------------
707
708    #[test]
709    fn triangular_mf_peak() {
710        let mf = MembershipFunction::Triangular {
711            a: 0.0,
712            b: 5.0,
713            c: 10.0,
714        };
715        assert!((mf.evaluate(5.0) - 1.0).abs() < 1e-10);
716    }
717
718    #[test]
719    fn triangular_mf_left_edge() {
720        let mf = MembershipFunction::Triangular {
721            a: 0.0,
722            b: 5.0,
723            c: 10.0,
724        };
725        assert!((mf.evaluate(0.0)).abs() < 1e-10);
726    }
727
728    #[test]
729    fn triangular_mf_right_edge() {
730        let mf = MembershipFunction::Triangular {
731            a: 0.0,
732            b: 5.0,
733            c: 10.0,
734        };
735        assert!((mf.evaluate(10.0)).abs() < 1e-10);
736    }
737
738    #[test]
739    fn triangular_mf_outside_range() {
740        let mf = MembershipFunction::Triangular {
741            a: 0.0,
742            b: 5.0,
743            c: 10.0,
744        };
745        assert_eq!(mf.evaluate(-1.0), 0.0);
746        assert_eq!(mf.evaluate(11.0), 0.0);
747    }
748
749    #[test]
750    fn triangular_mf_midpoint_left() {
751        let mf = MembershipFunction::Triangular {
752            a: 0.0,
753            b: 4.0,
754            c: 8.0,
755        };
756        // At x=2, μ = (2-0)/(4-0) = 0.5
757        assert!((mf.evaluate(2.0) - 0.5).abs() < 1e-10);
758    }
759
760    #[test]
761    fn trapezoidal_mf_flat_top() {
762        let mf = MembershipFunction::Trapezoidal {
763            a: 0.0,
764            b: 2.0,
765            c: 8.0,
766            d: 10.0,
767        };
768        // Inside flat top [2,8]
769        assert!((mf.evaluate(5.0) - 1.0).abs() < 1e-10);
770    }
771
772    #[test]
773    fn trapezoidal_mf_rising_slope() {
774        let mf = MembershipFunction::Trapezoidal {
775            a: 0.0,
776            b: 4.0,
777            c: 8.0,
778            d: 10.0,
779        };
780        // At x=2 on rising slope: (2-0)/(4-0) = 0.5
781        assert!((mf.evaluate(2.0) - 0.5).abs() < 1e-10);
782    }
783
784    #[test]
785    fn trapezoidal_mf_falling_slope() {
786        let mf = MembershipFunction::Trapezoidal {
787            a: 0.0,
788            b: 2.0,
789            c: 8.0,
790            d: 10.0,
791        };
792        // At x=9 on falling slope: (10-9)/(10-8) = 0.5
793        assert!((mf.evaluate(9.0) - 0.5).abs() < 1e-10);
794    }
795
796    #[test]
797    fn trapezoidal_mf_outside() {
798        let mf = MembershipFunction::Trapezoidal {
799            a: 2.0,
800            b: 4.0,
801            c: 6.0,
802            d: 8.0,
803        };
804        assert_eq!(mf.evaluate(1.0), 0.0);
805        assert_eq!(mf.evaluate(9.0), 0.0);
806    }
807
808    #[test]
809    fn gaussian_mf_at_mean() {
810        let mf = MembershipFunction::Gaussian {
811            mean: 5.0,
812            sigma: 1.0,
813        };
814        assert!((mf.evaluate(5.0) - 1.0).abs() < 1e-10);
815    }
816
817    #[test]
818    fn gaussian_mf_sigma_symmetry() {
819        let mf = MembershipFunction::Gaussian {
820            mean: 0.0,
821            sigma: 2.0,
822        };
823        // Symmetric around mean
824        let left = mf.evaluate(-1.0);
825        let right = mf.evaluate(1.0);
826        assert!((left - right).abs() < 1e-10);
827    }
828
829    #[test]
830    fn gaussian_mf_decay() {
831        let mf = MembershipFunction::Gaussian {
832            mean: 0.0,
833            sigma: 1.0,
834        };
835        // At x=1, μ = exp(-0.5) ≈ 0.6065
836        assert!((mf.evaluate(1.0) - std::f64::consts::E.powf(-0.5)).abs() < 1e-10);
837    }
838
839    #[test]
840    fn singleton_mf_exact() {
841        let mf = MembershipFunction::Singleton { value: 3.0 };
842        assert!((mf.evaluate(3.0) - 1.0).abs() < 1e-10);
843        assert_eq!(mf.evaluate(3.1), 0.0);
844    }
845
846    #[test]
847    fn universe_mf_always_one() {
848        let mf = MembershipFunction::Universe;
849        assert!((mf.evaluate(-100.0) - 1.0).abs() < 1e-10);
850        assert!((mf.evaluate(0.0) - 1.0).abs() < 1e-10);
851        assert!((mf.evaluate(100.0) - 1.0).abs() < 1e-10);
852    }
853
854    // -----------------------------------------------------------------------
855    // FuzzySet tests
856    // -----------------------------------------------------------------------
857
858    #[test]
859    fn fuzzy_set_degree_delegates_to_mf() {
860        let set = FuzzySet::new(
861            "hot",
862            MembershipFunction::Triangular {
863                a: 5.0,
864                b: 10.0,
865                c: 15.0,
866            },
867        );
868        assert!((set.degree(10.0) - 1.0).abs() < 1e-10);
869        assert_eq!(set.degree(5.0), 0.0);
870    }
871
872    // -----------------------------------------------------------------------
873    // FuzzyVariable tests
874    // -----------------------------------------------------------------------
875
876    #[test]
877    fn fuzzy_variable_add_and_get_set() {
878        let mut var = FuzzyVariable::new("temp", 0.0, 100.0);
879        var.add_set(FuzzySet::new(
880            "low",
881            MembershipFunction::Triangular {
882                a: 0.0,
883                b: 0.0,
884                c: 50.0,
885            },
886        ));
887        var.add_set(FuzzySet::new(
888            "high",
889            MembershipFunction::Triangular {
890                a: 50.0,
891                b: 100.0,
892                c: 100.0,
893            },
894        ));
895        assert!(var.get_set("low").is_some());
896        assert!(var.get_set("medium").is_none());
897    }
898
899    // -----------------------------------------------------------------------
900    // Engine construction and registration
901    // -----------------------------------------------------------------------
902
903    fn build_temperature_engine() -> FuzzyLogicEngine {
904        let mut engine = FuzzyLogicEngine::new(InferenceMethod::Mamdani, DefuzzMethod::Centroid);
905
906        // Input variable: temperature [0, 100]
907        // Use Trapezoidal for cold/hot so the endpoints have a full-membership plateau.
908        let mut temp = FuzzyVariable::new("temperature", 0.0, 100.0);
909        temp.add_set(FuzzySet::new(
910            "cold",
911            MembershipFunction::Trapezoidal {
912                a: 0.0,
913                b: 0.0,
914                c: 20.0,
915                d: 40.0,
916            },
917        ));
918        temp.add_set(FuzzySet::new(
919            "warm",
920            MembershipFunction::Triangular {
921                a: 30.0,
922                b: 50.0,
923                c: 70.0,
924            },
925        ));
926        temp.add_set(FuzzySet::new(
927            "hot",
928            MembershipFunction::Trapezoidal {
929                a: 60.0,
930                b: 80.0,
931                c: 100.0,
932                d: 100.0,
933            },
934        ));
935        engine.add_variable(temp).expect("add temperature");
936
937        // Output variable: fan_speed [0, 100]
938        let mut fan = FuzzyVariable::new("fan_speed", 0.0, 100.0);
939        fan.add_set(FuzzySet::new(
940            "slow",
941            MembershipFunction::Trapezoidal {
942                a: 0.0,
943                b: 0.0,
944                c: 20.0,
945                d: 40.0,
946            },
947        ));
948        fan.add_set(FuzzySet::new(
949            "medium",
950            MembershipFunction::Triangular {
951                a: 30.0,
952                b: 50.0,
953                c: 70.0,
954            },
955        ));
956        fan.add_set(FuzzySet::new(
957            "fast",
958            MembershipFunction::Trapezoidal {
959                a: 60.0,
960                b: 80.0,
961                c: 100.0,
962                d: 100.0,
963            },
964        ));
965        engine.add_variable(fan).expect("add fan_speed");
966
967        // Rules
968        engine
969            .add_rule(FuzzyRule::new(
970                vec![FuzzyProposition::new("temperature", "cold")],
971                FuzzyProposition::new("fan_speed", "slow"),
972                1.0,
973            ))
974            .expect("add rule cold->slow");
975        engine
976            .add_rule(FuzzyRule::new(
977                vec![FuzzyProposition::new("temperature", "warm")],
978                FuzzyProposition::new("fan_speed", "medium"),
979                1.0,
980            ))
981            .expect("add rule warm->medium");
982        engine
983            .add_rule(FuzzyRule::new(
984                vec![FuzzyProposition::new("temperature", "hot")],
985                FuzzyProposition::new("fan_speed", "fast"),
986                1.0,
987            ))
988            .expect("add rule hot->fast");
989
990        engine
991    }
992
993    #[test]
994    fn engine_duplicate_variable_error() {
995        let mut engine = FuzzyLogicEngine::new(InferenceMethod::Mamdani, DefuzzMethod::Centroid);
996        let var = FuzzyVariable::new("x", 0.0, 1.0);
997        engine.add_variable(var.clone()).expect("first add");
998        let err = engine.add_variable(var);
999        assert!(matches!(err, Err(FuzzyError::DuplicateVariable(_))));
1000    }
1001
1002    #[test]
1003    fn engine_add_rule_missing_variable_error() {
1004        let mut engine = FuzzyLogicEngine::new(InferenceMethod::Mamdani, DefuzzMethod::Centroid);
1005        let rule = FuzzyRule::new(
1006            vec![FuzzyProposition::new("nonexistent", "set")],
1007            FuzzyProposition::new("out", "s"),
1008            1.0,
1009        );
1010        let err = engine.add_rule(rule);
1011        assert!(matches!(err, Err(FuzzyError::VariableNotFound(_))));
1012    }
1013
1014    #[test]
1015    fn engine_add_rule_missing_set_error() {
1016        let mut engine = FuzzyLogicEngine::new(InferenceMethod::Mamdani, DefuzzMethod::Centroid);
1017        let mut var = FuzzyVariable::new("x", 0.0, 10.0);
1018        var.add_set(FuzzySet::new("low", MembershipFunction::Universe));
1019        engine.add_variable(var).expect("add var");
1020        // Reference a set that doesn't exist on "x"
1021        let rule = FuzzyRule::new(
1022            vec![FuzzyProposition::new("x", "nonexistent_set")],
1023            FuzzyProposition::new("x", "low"),
1024            1.0,
1025        );
1026        let err = engine.add_rule(rule);
1027        assert!(matches!(err, Err(FuzzyError::SetNotFound { .. })));
1028    }
1029
1030    #[test]
1031    fn engine_rule_count() {
1032        let engine = build_temperature_engine();
1033        assert_eq!(engine.rule_count(), 3);
1034    }
1035
1036    #[test]
1037    fn engine_variable_names_count() {
1038        let engine = build_temperature_engine();
1039        assert_eq!(engine.variable_names().len(), 2);
1040    }
1041
1042    // -----------------------------------------------------------------------
1043    // Fuzzify tests
1044    // -----------------------------------------------------------------------
1045
1046    #[test]
1047    fn fuzzify_returns_all_sets() {
1048        let engine = build_temperature_engine();
1049        let result = engine.fuzzify("temperature", 50.0).expect("fuzzify");
1050        assert_eq!(result.len(), 3);
1051    }
1052
1053    #[test]
1054    fn fuzzify_unknown_variable() {
1055        let engine = build_temperature_engine();
1056        let err = engine.fuzzify("humidity", 50.0);
1057        assert!(matches!(err, Err(FuzzyError::VariableNotFound(_))));
1058    }
1059
1060    // -----------------------------------------------------------------------
1061    // Fire rule tests
1062    // -----------------------------------------------------------------------
1063
1064    #[test]
1065    fn fire_rule_full_activation() {
1066        let engine = build_temperature_engine();
1067        let mut inputs = HashMap::new();
1068        inputs.insert("temperature".to_string(), 0.0_f64); // fully cold
1069        let rule = &engine.rules[0]; // cold -> slow
1070        let alpha = engine.fire_rule(rule, &inputs).expect("fire rule");
1071        assert!((alpha - 1.0).abs() < 1e-10);
1072    }
1073
1074    #[test]
1075    fn fire_rule_zero_activation() {
1076        let engine = build_temperature_engine();
1077        let mut inputs = HashMap::new();
1078        inputs.insert("temperature".to_string(), 100.0_f64); // fully hot, not cold
1079        let rule = &engine.rules[0]; // cold -> slow (should fire at 0)
1080        let alpha = engine.fire_rule(rule, &inputs).expect("fire rule");
1081        assert!(alpha < 1e-10);
1082    }
1083
1084    #[test]
1085    fn fire_rule_weight_applied() {
1086        let engine = build_temperature_engine();
1087        // Build a rule with weight 0.5
1088        let mut inputs = HashMap::new();
1089        inputs.insert("temperature".to_string(), 0.0_f64);
1090        let rule = FuzzyRule::new(
1091            vec![FuzzyProposition::new("temperature", "cold")],
1092            FuzzyProposition::new("fan_speed", "slow"),
1093            0.5,
1094        );
1095        let alpha = engine.fire_rule(&rule, &inputs).expect("fire rule");
1096        assert!((alpha - 0.5).abs() < 1e-10);
1097    }
1098
1099    // -----------------------------------------------------------------------
1100    // Mamdani inference tests
1101    // -----------------------------------------------------------------------
1102
1103    #[test]
1104    fn mamdani_cold_input_gives_low_speed() {
1105        let engine = build_temperature_engine();
1106        let mut inputs = HashMap::new();
1107        inputs.insert("temperature".to_string(), 5.0_f64);
1108        let speed = engine.infer(&inputs, "fan_speed").expect("infer");
1109        // Cold input should yield slow (low) fan speed
1110        assert!(speed < 40.0, "expected slow speed, got {speed}");
1111    }
1112
1113    #[test]
1114    fn mamdani_hot_input_gives_high_speed() {
1115        let engine = build_temperature_engine();
1116        let mut inputs = HashMap::new();
1117        inputs.insert("temperature".to_string(), 95.0_f64);
1118        let speed = engine.infer(&inputs, "fan_speed").expect("infer");
1119        // Hot input should yield fast fan speed
1120        assert!(speed > 60.0, "expected fast speed, got {speed}");
1121    }
1122
1123    #[test]
1124    fn mamdani_no_rules_for_output_error() {
1125        let engine = build_temperature_engine();
1126        let inputs = HashMap::new();
1127        let err = engine.infer(&inputs, "temperature");
1128        assert!(matches!(err, Err(FuzzyError::NoRulesForOutput(_))));
1129    }
1130
1131    // -----------------------------------------------------------------------
1132    // Sugeno inference tests
1133    // -----------------------------------------------------------------------
1134
1135    #[test]
1136    fn sugeno_cold_input_gives_low_speed() {
1137        let mut engine = build_temperature_engine();
1138        // Switch to Sugeno
1139        engine.inference = InferenceMethod::Sugeno;
1140        let mut inputs = HashMap::new();
1141        inputs.insert("temperature".to_string(), 5.0_f64);
1142        let speed = engine.infer(&inputs, "fan_speed").expect("infer sugeno");
1143        assert!(speed < 40.0, "expected slow speed, got {speed}");
1144    }
1145
1146    #[test]
1147    fn sugeno_hot_input_gives_high_speed() {
1148        let mut engine = build_temperature_engine();
1149        engine.inference = InferenceMethod::Sugeno;
1150        let mut inputs = HashMap::new();
1151        inputs.insert("temperature".to_string(), 95.0_f64);
1152        let speed = engine.infer(&inputs, "fan_speed").expect("infer sugeno");
1153        assert!(speed > 60.0, "expected fast speed, got {speed}");
1154    }
1155
1156    // -----------------------------------------------------------------------
1157    // Defuzzification method tests
1158    // -----------------------------------------------------------------------
1159
1160    #[test]
1161    fn mean_of_max_defuzz() {
1162        let mut engine = build_temperature_engine();
1163        engine.defuzz = DefuzzMethod::MeanOfMax;
1164        let mut inputs = HashMap::new();
1165        inputs.insert("temperature".to_string(), 50.0_f64);
1166        let speed = engine.infer(&inputs, "fan_speed").expect("infer mom");
1167        assert!((0.0..=100.0).contains(&speed));
1168    }
1169
1170    #[test]
1171    fn largest_of_max_defuzz() {
1172        let mut engine = build_temperature_engine();
1173        engine.defuzz = DefuzzMethod::LargestOfMax;
1174        let mut inputs = HashMap::new();
1175        inputs.insert("temperature".to_string(), 50.0_f64);
1176        let speed = engine.infer(&inputs, "fan_speed").expect("infer lom");
1177        assert!((0.0..=100.0).contains(&speed));
1178    }
1179
1180    // -----------------------------------------------------------------------
1181    // Membership at helper test
1182    // -----------------------------------------------------------------------
1183
1184    #[test]
1185    fn membership_at_correct_value() {
1186        let engine = build_temperature_engine();
1187        let mu = engine
1188            .membership_at("temperature", "warm", 50.0)
1189            .expect("membership_at");
1190        assert!((mu - 1.0).abs() < 1e-10);
1191    }
1192
1193    #[test]
1194    fn membership_at_unknown_set() {
1195        let engine = build_temperature_engine();
1196        let err = engine.membership_at("temperature", "boiling", 50.0);
1197        assert!(matches!(err, Err(FuzzyError::SetNotFound { .. })));
1198    }
1199
1200    #[test]
1201    fn membership_at_unknown_variable() {
1202        let engine = build_temperature_engine();
1203        let err = engine.membership_at("pressure", "low", 50.0);
1204        assert!(matches!(err, Err(FuzzyError::VariableNotFound(_))));
1205    }
1206
1207    // -----------------------------------------------------------------------
1208    // evaluate() aliasing test
1209    // -----------------------------------------------------------------------
1210
1211    #[test]
1212    fn evaluate_same_as_infer() {
1213        let mut engine = build_temperature_engine();
1214        let mut inputs = HashMap::new();
1215        inputs.insert("temperature".to_string(), 60.0_f64);
1216        let via_infer = engine.infer(&inputs, "fan_speed").expect("infer");
1217        let via_evaluate = engine
1218            .evaluate(&inputs.clone(), "fan_speed")
1219            .expect("evaluate");
1220        assert!((via_infer - via_evaluate).abs() < 1e-10);
1221    }
1222
1223    // -----------------------------------------------------------------------
1224    // Stats test
1225    // -----------------------------------------------------------------------
1226
1227    #[test]
1228    fn stats_reports_correct_counts() {
1229        let engine = build_temperature_engine();
1230        let stats = engine.stats();
1231        assert_eq!(stats.variable_count, 2);
1232        assert_eq!(stats.rule_count, 3);
1233        assert!(stats.inference.contains("Mamdani"));
1234        assert!(stats.defuzz.contains("Centroid"));
1235    }
1236
1237    // -----------------------------------------------------------------------
1238    // Multi-antecedent AND rule test
1239    // -----------------------------------------------------------------------
1240
1241    #[test]
1242    fn multi_antecedent_and_rule() {
1243        let mut engine = FuzzyLogicEngine::new(InferenceMethod::Mamdani, DefuzzMethod::Centroid);
1244        // Two inputs: temperature and humidity
1245        let mut temp = FuzzyVariable::new("temperature", 0.0, 100.0);
1246        temp.add_set(FuzzySet::new(
1247            "hot",
1248            MembershipFunction::Trapezoidal {
1249                a: 50.0,
1250                b: 75.0,
1251                c: 100.0,
1252                d: 100.0,
1253            },
1254        ));
1255        engine.add_variable(temp).expect("temp");
1256
1257        let mut hum = FuzzyVariable::new("humidity", 0.0, 100.0);
1258        hum.add_set(FuzzySet::new(
1259            "high",
1260            MembershipFunction::Trapezoidal {
1261                a: 50.0,
1262                b: 75.0,
1263                c: 100.0,
1264                d: 100.0,
1265            },
1266        ));
1267        engine.add_variable(hum).expect("hum");
1268
1269        let mut comfort = FuzzyVariable::new("discomfort", 0.0, 100.0);
1270        comfort.add_set(FuzzySet::new(
1271            "severe",
1272            MembershipFunction::Trapezoidal {
1273                a: 50.0,
1274                b: 75.0,
1275                c: 100.0,
1276                d: 100.0,
1277            },
1278        ));
1279        engine.add_variable(comfort).expect("comfort");
1280
1281        // IF temperature IS hot AND humidity IS high THEN discomfort IS severe
1282        engine
1283            .add_rule(FuzzyRule::new(
1284                vec![
1285                    FuzzyProposition::new("temperature", "hot"),
1286                    FuzzyProposition::new("humidity", "high"),
1287                ],
1288                FuzzyProposition::new("discomfort", "severe"),
1289                1.0,
1290            ))
1291            .expect("add multi rule");
1292
1293        let mut inputs = HashMap::new();
1294        inputs.insert("temperature".to_string(), 90.0_f64);
1295        inputs.insert("humidity".to_string(), 90.0_f64);
1296        let result = engine.infer(&inputs, "discomfort").expect("multi infer");
1297        // Both inputs are hot/high — should yield some discomfort
1298        assert!(result > 50.0, "expected high discomfort, got {result}");
1299    }
1300
1301    #[test]
1302    fn multi_antecedent_and_takes_min() {
1303        let mut engine = FuzzyLogicEngine::new(InferenceMethod::Mamdani, DefuzzMethod::Centroid);
1304
1305        let mut a = FuzzyVariable::new("a", 0.0, 1.0);
1306        a.add_set(FuzzySet::new("s", MembershipFunction::Universe));
1307        engine.add_variable(a).expect("a");
1308
1309        let mut b = FuzzyVariable::new("b", 0.0, 1.0);
1310        b.add_set(FuzzySet::new(
1311            "s",
1312            MembershipFunction::Triangular {
1313                a: 0.0,
1314                b: 0.0,
1315                c: 1.0,
1316            },
1317        ));
1318        engine.add_variable(b).expect("b");
1319
1320        let mut out = FuzzyVariable::new("out", 0.0, 1.0);
1321        out.add_set(FuzzySet::new("s", MembershipFunction::Universe));
1322        engine.add_variable(out).expect("out");
1323
1324        engine
1325            .add_rule(FuzzyRule::new(
1326                vec![
1327                    FuzzyProposition::new("a", "s"), // degree = 1 always
1328                    FuzzyProposition::new("b", "s"), // degree = 1 - b_val
1329                ],
1330                FuzzyProposition::new("out", "s"),
1331                1.0,
1332            ))
1333            .expect("rule");
1334
1335        let mut inputs = HashMap::new();
1336        inputs.insert("a".to_string(), 0.5_f64);
1337        inputs.insert("b".to_string(), 0.0_f64); // b at left edge => degree = 1.0 (triangular 0-0-1 at x=0)
1338
1339        // a IS Universe (degree=1), b IS Triangular at x=0 => degree=1 (peak at 0)
1340        // min(1, 1) = 1 → out IS Universe clipped at 1 → centroid = 0.5
1341        let result = engine.infer(&inputs, "out").expect("and min");
1342        assert!((result - 0.5).abs() < 0.1, "expected ~0.5, got {result}");
1343    }
1344
1345    // -----------------------------------------------------------------------
1346    // Gaussian MF integration test
1347    // -----------------------------------------------------------------------
1348
1349    #[test]
1350    fn infer_with_gaussian_mf() {
1351        let mut engine = FuzzyLogicEngine::new(InferenceMethod::Mamdani, DefuzzMethod::Centroid);
1352
1353        let mut inp = FuzzyVariable::new("signal", 0.0, 10.0);
1354        inp.add_set(FuzzySet::new(
1355            "mid",
1356            MembershipFunction::Gaussian {
1357                mean: 5.0,
1358                sigma: 1.0,
1359            },
1360        ));
1361        engine.add_variable(inp).expect("inp");
1362
1363        let mut out = FuzzyVariable::new("output", 0.0, 10.0);
1364        out.add_set(FuzzySet::new(
1365            "mid",
1366            MembershipFunction::Gaussian {
1367                mean: 5.0,
1368                sigma: 1.0,
1369            },
1370        ));
1371        engine.add_variable(out).expect("out");
1372
1373        engine
1374            .add_rule(FuzzyRule::new(
1375                vec![FuzzyProposition::new("signal", "mid")],
1376                FuzzyProposition::new("output", "mid"),
1377                1.0,
1378            ))
1379            .expect("gaussian rule");
1380
1381        let mut inputs = HashMap::new();
1382        inputs.insert("signal".to_string(), 5.0_f64); // exactly at mean
1383        let result = engine.infer(&inputs, "output").expect("gaussian infer");
1384        // Rule fires at degree 1 → centroid of gaussian centred at 5 ≈ 5
1385        assert!((result - 5.0).abs() < 0.5, "expected ~5.0, got {result}");
1386    }
1387
1388    // -----------------------------------------------------------------------
1389    // Sugeno no-activation numerical error
1390    // -----------------------------------------------------------------------
1391
1392    #[test]
1393    fn sugeno_zero_activation_numerical_error() {
1394        let mut engine = FuzzyLogicEngine::new(InferenceMethod::Sugeno, DefuzzMethod::Centroid);
1395
1396        let mut inp = FuzzyVariable::new("x", 0.0, 10.0);
1397        inp.add_set(FuzzySet::new(
1398            "low",
1399            MembershipFunction::Triangular {
1400                a: 0.0,
1401                b: 0.0,
1402                c: 5.0,
1403            },
1404        ));
1405        engine.add_variable(inp).expect("x");
1406
1407        let mut out = FuzzyVariable::new("y", 0.0, 10.0);
1408        out.add_set(FuzzySet::new("s", MembershipFunction::Universe));
1409        engine.add_variable(out).expect("y");
1410
1411        engine
1412            .add_rule(FuzzyRule::new(
1413                vec![FuzzyProposition::new("x", "low")],
1414                FuzzyProposition::new("y", "s"),
1415                1.0,
1416            ))
1417            .expect("rule");
1418
1419        // At x=10, "low" has zero degree → Sugeno total weight is 0
1420        let mut inputs = HashMap::new();
1421        inputs.insert("x".to_string(), 10.0_f64);
1422        let err = engine.infer(&inputs, "y");
1423        assert!(matches!(err, Err(FuzzyError::NumericalError(_))));
1424    }
1425}