Skip to main content

qualia_core_db/modalities/
fuzzy.rs

1use crate::NQuin;
2
3/// Many-valued / fuzzy logic over truth degrees in `[0, 1]`. Distinct from the
4/// Bayesian `probabilistic` modality: fuzzy conjunction uses a t-norm (not a
5/// product), modelling DEGREES of (partial) satisfaction — e.g. a right that is
6/// partially fulfilled. Each proposition carries its truth degree as an f32 in the
7/// quin `metadata`. Zero-heap throughout.
8
9/// Gödel t-norm (fuzzy AND) — the minimum.
10#[inline]
11pub fn t_norm_godel(a: f32, b: f32) -> f32 {
12    a.min(b)
13}
14
15/// Łukasiewicz t-norm (fuzzy AND) — `max(0, a + b - 1)`.
16#[inline]
17pub fn t_norm_lukasiewicz(a: f32, b: f32) -> f32 {
18    (a + b - 1.0).max(0.0)
19}
20
21/// Gödel t-conorm (fuzzy OR) — the maximum.
22#[inline]
23pub fn t_conorm_godel(a: f32, b: f32) -> f32 {
24    a.max(b)
25}
26
27/// Read a proposition's fuzzy truth degree (canonical f32 in `metadata`, via the
28/// FrameLayout ABI), clamped to [0,1].
29#[inline]
30pub fn degree(quin: &NQuin) -> f32 {
31    crate::frame_layout::truth_degree(quin.metadata).clamp(0.0, 1.0)
32}
33
34/// Fuzzy conjunction (Gödel t-norm = min) of the truth degrees carried by `quins`.
35/// Empty input → 1.0 (the t-norm identity). Zero-heap.
36pub fn conjunction(quins: &[NQuin]) -> f32 {
37    let mut acc = 1.0f32;
38    for q in quins {
39        acc = t_norm_godel(acc, degree(q));
40    }
41    acc
42}
43
44// ─── T-norm / T-conorm families (Gödel, Łukasiewicz, Product, Drastic) ──────────────
45
46/// Łukasiewicz t-conorm (fuzzy OR) — `min(1, a + b)`.
47#[inline]
48pub fn t_conorm_lukasiewicz(a: f32, b: f32) -> f32 {
49    (a + b).min(1.0)
50}
51
52/// Product t-norm (fuzzy AND) — `a · b` (the probabilistic/algebraic conjunction).
53#[inline]
54pub fn t_norm_product(a: f32, b: f32) -> f32 {
55    a * b
56}
57
58/// Product t-conorm (fuzzy OR) — `a + b - a·b` (the probabilistic sum).
59#[inline]
60pub fn t_conorm_product(a: f32, b: f32) -> f32 {
61    a + b - a * b
62}
63
64/// Drastic t-norm — the smallest t-norm: `b` if `a==1`, `a` if `b==1`, else `0`.
65#[inline]
66pub fn t_norm_drastic(a: f32, b: f32) -> f32 {
67    if a >= 1.0 {
68        b
69    } else if b >= 1.0 {
70        a
71    } else {
72        0.0
73    }
74}
75
76/// Drastic t-conorm — the largest t-conorm: `b` if `a==0`, `a` if `b==0`, else `1`.
77#[inline]
78pub fn t_conorm_drastic(a: f32, b: f32) -> f32 {
79    if a <= 0.0 {
80        b
81    } else if b <= 0.0 {
82        a
83    } else {
84        1.0
85    }
86}
87
88/// Standard fuzzy negation (complement) — `1 - a`, clamped to [0,1].
89#[inline]
90pub fn fuzzy_not(a: f32) -> f32 {
91    (1.0 - a).clamp(0.0, 1.0)
92}
93
94/// Fuzzy disjunction (Gödel t-conorm = max) of the truth degrees carried by `quins`.
95/// Empty input → 0.0 (the t-conorm identity). Zero-heap.
96pub fn disjunction(quins: &[NQuin]) -> f32 {
97    let mut acc = 0.0f32;
98    for q in quins {
99        acc = t_conorm_godel(acc, degree(q));
100    }
101    acc
102}
103
104// ─── Linguistic hedges (Zadeh) ──────────────────────────────────────────────────────
105
106/// Concentration hedge "very" — `μ²` (sharpens, lowers partial memberships).
107#[inline]
108pub fn hedge_very(mu: f32) -> f32 {
109    mu * mu
110}
111
112/// Concentration hedge "extremely" — `μ³`.
113#[inline]
114pub fn hedge_extremely(mu: f32) -> f32 {
115    mu * mu * mu
116}
117
118/// Dilation hedge "more or less" / "somewhat" — `√μ` (broadens, raises partial memberships).
119#[inline]
120pub fn hedge_more_or_less(mu: f32) -> f32 {
121    mu.max(0.0).sqrt()
122}
123
124// ─── Defuzzification ────────────────────────────────────────────────────────────────
125//
126// A fuzzy output set is given as a discretised universe `u` (assumed monotonically
127// increasing) with parallel membership `mu`. Each method collapses it to a crisp value.
128// `None` when the slices mismatch / are empty / carry no mass. Zero-heap (slice scans).
129
130/// Centroid / centre-of-gravity (COG): `Σ(uᵢ·μᵢ) / Σ μᵢ`.
131pub fn defuzz_centroid(u: &[f32], mu: &[f32]) -> Option<f32> {
132    if u.len() != mu.len() || u.is_empty() {
133        return None;
134    }
135    let mut num = 0.0f32;
136    let mut den = 0.0f32;
137    for i in 0..u.len() {
138        num += u[i] * mu[i];
139        den += mu[i];
140    }
141    if den.abs() < 1e-9 {
142        None
143    } else {
144        Some(num / den)
145    }
146}
147
148/// Mean-of-Maximum (MOM): the mean of the universe points attaining maximum membership.
149pub fn defuzz_mean_of_max(u: &[f32], mu: &[f32]) -> Option<f32> {
150    if u.len() != mu.len() || u.is_empty() {
151        return None;
152    }
153    let mut max = f32::MIN;
154    for &m in mu {
155        if m > max {
156            max = m;
157        }
158    }
159    let mut sum = 0.0f32;
160    let mut n = 0u32;
161    for i in 0..u.len() {
162        if (mu[i] - max).abs() < 1e-6 {
163            sum += u[i];
164            n += 1;
165        }
166    }
167    if n == 0 {
168        None
169    } else {
170        Some(sum / n as f32)
171    }
172}
173
174/// Smallest-of-Maximum (SOM): the smallest universe point attaining maximum membership.
175pub fn defuzz_smallest_of_max(u: &[f32], mu: &[f32]) -> Option<f32> {
176    if u.len() != mu.len() || u.is_empty() {
177        return None;
178    }
179    let mut max = f32::MIN;
180    for &m in mu {
181        if m > max {
182            max = m;
183        }
184    }
185    for i in 0..u.len() {
186        if (mu[i] - max).abs() < 1e-6 {
187            return Some(u[i]); // u is increasing → first match is smallest
188        }
189    }
190    None
191}
192
193/// Bisector: the universe point that splits the area under `μ` into two equal halves.
194pub fn defuzz_bisector(u: &[f32], mu: &[f32]) -> Option<f32> {
195    if u.len() != mu.len() || u.is_empty() {
196        return None;
197    }
198    let total: f32 = mu.iter().sum();
199    if total.abs() < 1e-9 {
200        return None;
201    }
202    let half = total / 2.0;
203    let mut cum = 0.0f32;
204    for i in 0..u.len() {
205        cum += mu[i];
206        if cum >= half {
207            return Some(u[i]);
208        }
209    }
210    Some(u[u.len() - 1])
211}
212
213// ─── Fuzzy Inference Systems (Mamdani & Sugeno) ─────────────────────────────────────
214//
215// A FIS maps crisp/fuzzy inputs to a crisp output through a rule base. Antecedent membership
216// degrees (read from nquins via `degree()`) combine by a t-norm into a rule's FIRING STRENGTH;
217// the consequent is then either a fuzzy set (Mamdani) defuzzified by centroid, or a crisp
218// value combined by firing-weighted average (Sugeno/TSK). Zero-heap (caller-supplied scratch).
219
220/// Rule firing strength = Gödel t-norm (min) of the antecedent membership degrees. Empty
221/// antecedent → 1.0 (the t-norm identity). Use `degree()` to source each membership from a Quin.
222pub fn firing_strength(antecedent_mu: &[f32]) -> f32 {
223    let mut acc = 1.0f32;
224    for &m in antecedent_mu {
225        acc = t_norm_godel(acc, m);
226    }
227    acc
228}
229
230/// One Mamdani rule's contribution: its `firing` strength and its consequent membership function
231/// `consequent_mu` sampled over the shared output universe.
232#[derive(Debug, Clone, Copy)]
233pub struct MamdaniRule<'a> {
234    pub firing: f32,
235    pub consequent_mu: &'a [f32],
236}
237
238/// Mamdani inference: clip each rule's consequent at its firing strength (min-implication),
239/// aggregate across rules by `max` into `scratch`, then defuzzify by centroid over `universe`.
240/// `None` if the aggregate set carries no mass. Zero-heap (caller owns `scratch`, sized to the
241/// universe).
242pub fn mamdani_infer(universe: &[f32], rules: &[MamdaniRule], scratch: &mut [f32]) -> Option<f32> {
243    if scratch.len() != universe.len() {
244        return None;
245    }
246    for s in scratch.iter_mut() {
247        *s = 0.0;
248    }
249    for r in rules {
250        let n = scratch.len().min(r.consequent_mu.len());
251        for i in 0..n {
252            // min-implication (clip) then max-aggregation across rules.
253            let clipped = r.firing.min(r.consequent_mu[i]);
254            if clipped > scratch[i] {
255                scratch[i] = clipped;
256            }
257        }
258    }
259    defuzz_centroid(universe, scratch)
260}
261
262/// One Sugeno (TSK) rule: its `firing` strength and a crisp `consequent` value (a 0th-order
263/// constant, or a pre-evaluated 1st-order linear function of the inputs).
264#[derive(Debug, Clone, Copy)]
265pub struct SugenoRule {
266    pub firing: f32,
267    pub consequent: f32,
268}
269
270/// Sugeno (TSK) inference: the firing-strength-weighted average of rule consequents,
271/// `Σ(wᵢ·zᵢ) / Σ wᵢ`. `None` if total firing is ~0 (refuse rather than divide by zero).
272pub fn sugeno_infer(rules: &[SugenoRule]) -> Option<f32> {
273    let mut num = 0.0f32;
274    let mut den = 0.0f32;
275    for r in rules {
276        num += r.firing * r.consequent;
277        den += r.firing;
278    }
279    if den.abs() < 1e-9 {
280        None
281    } else {
282        Some(num / den)
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn t_norms_and_conjunction() {
292        assert!((t_norm_godel(0.7, 0.4) - 0.4).abs() < 1e-6);
293        assert!((t_norm_lukasiewicz(0.7, 0.4) - 0.1).abs() < 1e-6);
294        assert!((t_norm_lukasiewicz(0.3, 0.4) - 0.0).abs() < 1e-6);
295        assert!((t_conorm_godel(0.7, 0.4) - 0.7).abs() < 1e-6);
296
297        let mk = |d: f32| {
298            let mut q = NQuin::default();
299            q.metadata = d.to_bits() as u64;
300            q
301        };
302        // min(0.9, 0.6, 0.8) = 0.6
303        assert!((conjunction(&[mk(0.9), mk(0.6), mk(0.8)]) - 0.6).abs() < 1e-6);
304        assert!((conjunction(&[]) - 1.0).abs() < 1e-6);
305    }
306
307    fn close(a: f32, b: f32) -> bool {
308        (a - b).abs() < 1e-6
309    }
310
311    #[test]
312    fn t_norm_conorm_families() {
313        // Product family.
314        assert!(close(t_norm_product(0.5, 0.4), 0.2));
315        assert!(close(t_conorm_product(0.5, 0.4), 0.7)); // 0.5+0.4-0.2
316                                                         // Łukasiewicz t-conorm.
317        assert!(close(t_conorm_lukasiewicz(0.7, 0.4), 1.0)); // min(1, 1.1)
318        assert!(close(t_conorm_lukasiewicz(0.3, 0.4), 0.7));
319        // Drastic: identity only when one operand is the unit/zero, else collapses.
320        assert!(close(t_norm_drastic(1.0, 0.4), 0.4));
321        assert!(close(t_norm_drastic(0.6, 0.4), 0.0));
322        assert!(close(t_conorm_drastic(0.0, 0.4), 0.4));
323        assert!(close(t_conorm_drastic(0.6, 0.4), 1.0));
324        // Complement.
325        assert!(close(fuzzy_not(0.3), 0.7));
326        // Ordering: drastic ≤ product ≤ Gödel (t-norms); reverse for t-conorms.
327        let (a, b) = (0.6f32, 0.4f32);
328        assert!(t_norm_drastic(a, b) <= t_norm_product(a, b));
329        assert!(t_norm_product(a, b) <= t_norm_godel(a, b));
330    }
331
332    #[test]
333    fn hedges_concentrate_and_dilate() {
334        assert!(close(hedge_very(0.5), 0.25));
335        assert!(close(hedge_extremely(0.5), 0.125));
336        assert!(close(hedge_more_or_less(0.25), 0.5));
337        // "very" lowers a partial membership; "more or less" raises it.
338        assert!(hedge_very(0.6) < 0.6);
339        assert!(hedge_more_or_less(0.6) > 0.6);
340    }
341
342    #[test]
343    fn defuzzification_methods() {
344        // Symmetric triangle centred at 2.0 → centroid/bisector/MOM all 2.0.
345        let u = [0.0, 1.0, 2.0, 3.0, 4.0];
346        let mu = [0.0, 0.5, 1.0, 0.5, 0.0];
347        assert!(close(defuzz_centroid(&u, &mu).unwrap(), 2.0));
348        assert!(close(defuzz_mean_of_max(&u, &mu).unwrap(), 2.0));
349        assert!(close(defuzz_smallest_of_max(&u, &mu).unwrap(), 2.0));
350        assert!(close(defuzz_bisector(&u, &mu).unwrap(), 2.0));
351
352        // Plateau at the max over {1.0, 2.0}: MOM = 1.5, SOM = 1.0.
353        let mu2 = [0.0, 1.0, 1.0, 0.2, 0.0];
354        assert!(close(defuzz_mean_of_max(&u, &mu2).unwrap(), 1.5));
355        assert!(close(defuzz_smallest_of_max(&u, &mu2).unwrap(), 1.0));
356
357        // Degenerate inputs refuse rather than divide by zero.
358        assert!(defuzz_centroid(&u, &[0.0; 5]).is_none());
359        assert!(defuzz_centroid(&[1.0, 2.0], &[0.1]).is_none());
360        assert!(defuzz_centroid(&[], &[]).is_none());
361    }
362
363    #[test]
364    fn firing_strength_is_godel_t_norm() {
365        assert!(close(firing_strength(&[0.8, 0.5, 0.9]), 0.5));
366        assert!(close(firing_strength(&[]), 1.0));
367    }
368
369    #[test]
370    fn mamdani_fis_clips_aggregates_and_defuzzifies() {
371        // Universe 0..4. Two consequent sets: "low" peaked near 1, "high" peaked near 3.
372        let u = [0.0, 1.0, 2.0, 3.0, 4.0];
373        let low = [1.0, 1.0, 0.5, 0.0, 0.0];
374        let high = [0.0, 0.0, 0.5, 1.0, 1.0];
375        let mut scratch = [0.0f32; 5];
376
377        // Only "low" fires (strength 1.0) → output pulled toward the low end.
378        let r_low_only = [MamdaniRule {
379            firing: 1.0,
380            consequent_mu: &low,
381        }];
382        let y_low = mamdani_infer(&u, &r_low_only, &mut scratch).unwrap();
383        // Only "high" fires → output pulled toward the high end.
384        let r_high_only = [MamdaniRule {
385            firing: 1.0,
386            consequent_mu: &high,
387        }];
388        let y_high = mamdani_infer(&u, &r_high_only, &mut scratch).unwrap();
389        assert!(
390            y_low < y_high,
391            "low-only ({y_low}) must sit below high-only ({y_high})"
392        );
393        assert!(y_low < 2.0 && y_high > 2.0);
394
395        // Both fire equally → symmetric → centroid at the universe centre (2.0).
396        let both = [
397            MamdaniRule {
398                firing: 1.0,
399                consequent_mu: &low,
400            },
401            MamdaniRule {
402                firing: 1.0,
403                consequent_mu: &high,
404            },
405        ];
406        let y_both = mamdani_infer(&u, &both, &mut scratch).unwrap();
407        assert!(close(y_both, 2.0));
408
409        // Clipping: a weak firing strength on "high" lowers its contribution.
410        let weak_high = [MamdaniRule {
411            firing: 0.2,
412            consequent_mu: &high,
413        }];
414        assert!(mamdani_infer(&u, &weak_high, &mut scratch).unwrap() > 2.0);
415        // Mismatched scratch size refuses.
416        let mut bad = [0.0f32; 3];
417        assert!(mamdani_infer(&u, &both, &mut bad).is_none());
418    }
419
420    #[test]
421    fn sugeno_fis_is_firing_weighted_average() {
422        // Two rules: z=0 (firing 0.25) and z=10 (firing 0.75) → weighted avg 7.5.
423        let rules = [
424            SugenoRule {
425                firing: 0.25,
426                consequent: 0.0,
427            },
428            SugenoRule {
429                firing: 0.75,
430                consequent: 10.0,
431            },
432        ];
433        assert!(close(sugeno_infer(&rules).unwrap(), 7.5));
434        // Equal firing → plain average.
435        let eq = [
436            SugenoRule {
437                firing: 0.5,
438                consequent: 2.0,
439            },
440            SugenoRule {
441                firing: 0.5,
442                consequent: 6.0,
443            },
444        ];
445        assert!(close(sugeno_infer(&eq).unwrap(), 4.0));
446        // No firing → None.
447        assert!(sugeno_infer(&[SugenoRule {
448            firing: 0.0,
449            consequent: 9.0
450        }])
451        .is_none());
452    }
453}