sigmoid_q15/lib.rs
1// Copyright (C) 2026 Jorge Andre Castro
2//
3// Ce programme est un logiciel libre : vous pouvez le redistribuer et/ou le modifier
4// selon les termes de la Licence Publique Générale GNU telle que publiée par la
5// Free Software Foundation, soit la version 2 de la licence, soit (à votre convention)
6// n'importe quelle version ultérieure.
7
8//! # sigmoid-q15
9//!
10//! Fonction d'activation Sigmoid en virgule fixe Q15 pour systèmes embarqués.
11//!
12//! ## Caractéristiques
13//!
14//! - `#![no_std]` : aucune dépendance à la bibliothèque standard.
15//! - Arithmétique entière pure : pas de flottants, pas de `libm`.
16//! - Compatible **RP2040** (Cortex-M0+) et **RP2350** (Cortex-M33).
17//! - Utilise [`embedded-exp`](https://crates.io/crates/embedded-exp) pour le calcul de `e^x`.
18//! - Temps d'exécution **constant** (déterministe) : idéal pour les noyaux temps réel.
19//! - Zéro allocation dynamique : traitement in-place possible.
20//!
21//! ## Format Q15
22//!
23//! En Q15, un `i16` représente un nombre réel dans `[-1.0, 1.0[` :
24//! ```text
25//! valeur_réelle = valeur_i16 / 32768.0
26//! ```
27//!
28//! ## Algorithme
29//!
30//! `sigmoid(x) = 1 / (1 + e^(-x))`
31//!
32//! Le problème : `embedded-exp` ne fonctionne que sur le domaine **négatif**.
33//! On exploite la symétrie de sigmoid pour toujours rester sur ce domaine :
34//!
35//! ```text
36//! sigmoid(x) = 1 / (1 + e^(-x)) si x <= 0 → -x >= 0... non
37//! ```
38//!
39//! Plus précisément :
40//!
41//! - Si `x <= 0` : `-x >= 0`, mais `e^(-x)` avec `-x >= 0` saturerait.
42//! On utilise donc `sigmoid(x) = 1 - sigmoid(-x)` pour ramener
43//! le calcul sur le domaine négatif.
44//! - Si `x > 0` : `-x < 0` → `exp_q15(-x)` calcule normalement.
45//! - Si `x = 0` : `sigmoid(0) = 0.5` → `16384` en Q15.
46//!
47//! **Propriété exploitée :**
48//! ```text
49//! sigmoid(-x) = 1 - sigmoid(x)
50//! ```
51//! On calcule toujours `exp_q15` sur une valeur **négative ou nulle**,
52//! ce qui est exactement le domaine valide de `embedded-exp`.
53//!
54//! ## Exemple
55//!
56//! ```rust
57//! use sigmoid_q15::{sigmoid_q15, sigmoid_slice_q15};
58//!
59//! // sigmoid(0) = 0.5 → 16384 en Q15
60//! assert_eq!(sigmoid_q15(0), 16384);
61//!
62//! // sigmoid(-1.0) ≈ 0.2689 → ≈ 8808 en Q15
63//! let res = sigmoid_q15(-32768);
64//! assert!((res as i32 - 8808).abs() < 20);
65//!
66//! // sigmoid(x) est toujours dans ]0.0, 1.0[ → toujours positif
67//! assert!(sigmoid_q15(i16::MIN) > 0);
68//! assert!(sigmoid_q15(i16::MAX) > 0);
69//!
70//! // Traitement in-place
71//! let mut buf = [-32768i16, 0, 32767];
72//! sigmoid_slice_q15(&mut buf);
73//! assert!(buf[0] > 0);
74//! assert_eq!(buf[1], 16384);
75//! assert!(buf[2] > 16384);
76//! ```
77
78#![no_std]
79#![forbid(unsafe_code)]
80
81use embedded_exp::exp_q15;
82
83/// Un en Q15 : représente 1.0, mais i16::MAX = 32767 ≈ 1.0
84/// On utilise 32768 comme "1.0 exact" en i32 pour les calculs internes.
85const ONE_Q15: i32 = 32768;
86
87/// Calcule la fonction d'activation Sigmoid pour un nombre en virgule fixe Q15.
88///
89/// `sigmoid(x) = 1 / (1 + e^(-x))`
90///
91/// La sortie est toujours dans `]0.0, 1.0[`, soit `[1, 32767]` en Q15.
92///
93/// ## Stratégie
94///
95/// On exploite la symétrie `sigmoid(-x) = 1 - sigmoid(x)` pour toujours
96/// appeler `exp_q15` avec une valeur **négative**, son domaine valide.
97///
98/// | Entrée `x` | Calcul effectué |
99/// |-----------|----------------------------------------|
100/// | `x > 0` | `1 / (1 + exp(-x))` directement |
101/// | `x == 0` | retourne `16384` (0.5 exact) |
102/// | `x < 0` | `1 - sigmoid(-x)` par symétrie |
103///
104/// ## Cas limites
105///
106/// | Entrée | Valeur Q15 | Sortie attendue |
107/// |------------|-----------|---------------------|
108/// | `i16::MIN` | -32768 | ≈ 8808 (≈ 0.2689) |
109/// | `0` | 0 | 16384 (= 0.5 exact) |
110/// | `i16::MAX` | 32767 | ≈ 23960 (≈ 0.7311) |
111///
112/// # Exemple
113///
114/// ```rust
115/// use sigmoid_q15::sigmoid_q15;
116///
117/// assert_eq!(sigmoid_q15(0), 16384);
118/// assert!(sigmoid_q15(-32768) > 0);
119/// assert!(sigmoid_q15(32767) > 16384);
120/// ```
121#[inline]
122pub fn sigmoid_q15(x: i16) -> i16 {
123 // Cas x = 0 : sigmoid(0) = 0.5 = 16384 en Q15
124 if x == 0 {
125 return 16384;
126 }
127
128 if x > 0 {
129 // x > 0 → -x < 0 → exp_q15(-x) est dans le domaine valide
130 // sigmoid(x) = 1 / (1 + e^(-x))
131 sigmoid_positive(x)
132 } else {
133 // x < 0 → on utilise la symétrie : sigmoid(x) = 1 - sigmoid(-x)
134 // -x > 0 → sigmoid(-x) se calcule via sigmoid_positive(-x)
135 // Comme x >= i16::MIN, -x peut déborder si x == i16::MIN.
136 // On gère ce cas : si x == i16::MIN, on sature -x à i16::MAX.
137 let neg_x = if x == i16::MIN {
138 i16::MAX
139 } else {
140 -x
141 };
142 let s_neg_x = sigmoid_positive(neg_x) as i32;
143 // 1 - sigmoid(-x) en Q15
144 // ONE_Q15 = 32768, mais sigmoid retourne au max 32767
145 // On clamp à 1 minimum pour garantir > 0
146 let result = ONE_Q15 - s_neg_x;
147 result.max(1).min(32767) as i16
148 }
149}
150
151/// Calcul interne de sigmoid pour x > 0.
152/// Précondition : x > 0, donc -x < 0, donc exp_q15(-x) est valide.
153#[inline]
154fn sigmoid_positive(x: i16) -> i16 {
155 debug_assert!(x > 0);
156
157 // -x est négatif : domaine valide pour exp_q15
158 let neg_x = -x; // x > 0 et x != i16::MIN donc pas de débordement
159
160 // e^(-x) en Q15
161 let exp_neg_x = exp_q15(neg_x) as i32;
162
163 // 1 + e^(-x) en Q15
164 // ONE_Q15 = 32768 représente 1.0 ; exp_neg_x ∈ [1, 32767]
165 // Somme max : 32768 + 32767 = 65535 → tient dans i32
166 let denom = ONE_Q15 + exp_neg_x;
167
168 // sigmoid = 1 / (1 + e^(-x)) = ONE_Q15 / denom
169 // On multiplie numérateur par ONE_Q15 pour rester en Q15 :
170 // résultat = (ONE_Q15 * ONE_Q15) / denom
171 // ONE_Q15 * ONE_Q15 = 32768 * 32768 = 1_073_741_824 → tient dans i32 (max ~2.1e9)
172 let result = (ONE_Q15 * ONE_Q15) / denom;
173
174 // Saturation : résultat dans [1, 32767]
175 result.max(1).min(32767) as i16
176}
177
178/// Applique Sigmoid sur un slice de données en virgule fixe Q15, **in-place**.
179///
180/// Chaque élément `v` est remplacé par `sigmoid(v)`.
181/// L'opération in-place évite toute allocation dynamique.
182///
183/// ## Slice vide
184///
185/// Un slice vide (`&mut []`) est accepté sans erreur.
186///
187/// # Exemple
188///
189/// ```rust
190/// use sigmoid_q15::sigmoid_slice_q15;
191///
192/// let mut data = [-32768i16, 0, 32767];
193/// sigmoid_slice_q15(&mut data);
194/// // Tous les résultats sont dans ]0, 32767]
195/// assert!(data[0] > 0 && data[0] < 16384); // < 0.5
196/// assert_eq!(data[1], 16384); // = 0.5
197/// assert!(data[2] > 16384); // > 0.5
198///
199/// // Slice vide : aucun effet, aucune panique
200/// let mut empty: [i16; 0] = [];
201/// sigmoid_slice_q15(&mut empty);
202/// ```
203#[inline]
204pub fn sigmoid_slice_q15(data: &mut [i16]) {
205 for val in data.iter_mut() {
206 *val = sigmoid_q15(*val);
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 // Valeurs de référence
215 // sigmoid(0.0) = 0.5 → 16384 en Q15
216 // sigmoid(-1.0) ≈ 0.2689 → ≈ 8808 en Q15
217 // sigmoid(1.0) ≈ 0.7311 → ≈ 23960 en Q15
218 // sigmoid(-0.5) ≈ 0.3775 → ≈ 12372 en Q15
219 // sigmoid(0.5) ≈ 0.6225 → ≈ 20396 en Q15
220
221 const TOLERANCE: i32 = 30; // tolérance en ULP Q15
222
223 #[test]
224 fn test_sigmoid_zero_is_half() {
225 // sigmoid(0) = 0.5 exact → 16384 en Q15
226 assert_eq!(sigmoid_q15(0), 16384);
227 }
228
229 #[test]
230 fn test_sigmoid_negative_one() {
231 // sigmoid(-1.0) ≈ 0.2689 → ≈ 8808
232 let res = sigmoid_q15(-32768);
233 assert!(
234 (res as i32 - 8808).abs() < TOLERANCE,
235 "sigmoid(-1.0): attendu ≈8808, reçu {}",
236 res
237 );
238 }
239
240 #[test]
241 fn test_sigmoid_positive_one() {
242 // sigmoid(1.0) ≈ 0.7311 → ≈ 23960
243 // x = i16::MAX ≈ 1.0 en Q15
244 let res = sigmoid_q15(i16::MAX);
245 assert!(
246 (res as i32 - 23960).abs() < TOLERANCE,
247 "sigmoid(≈1.0): attendu ≈23960, reçu {}",
248 res
249 );
250 }
251
252 #[test]
253 fn test_sigmoid_negative_half() {
254 // sigmoid(-0.5) ≈ 0.3775 → ≈ 12372
255 let res = sigmoid_q15(-16384);
256 assert!(
257 (res as i32 - 12372).abs() < TOLERANCE,
258 "sigmoid(-0.5): attendu ≈12372, reçu {}",
259 res
260 );
261 }
262
263 #[test]
264 fn test_sigmoid_positive_half() {
265 // sigmoid(0.5) ≈ 0.6225 → ≈ 20396
266 let res = sigmoid_q15(16384);
267 assert!(
268 (res as i32 - 20396).abs() < TOLERANCE,
269 "sigmoid(0.5): attendu ≈20396, reçu {}",
270 res
271 );
272 }
273
274 #[test]
275 fn test_sigmoid_symmetry() {
276 // sigmoid(x) + sigmoid(-x) ≈ 1.0 (= 32768 en Q15)
277 // On exclut i16::MIN car -i16::MIN déborde en i16
278 for x in [-16384i16, -8192, -4096, -1] {
279 let s_pos = sigmoid_q15(-x) as i32;
280 let s_neg = sigmoid_q15(x) as i32;
281 let sum = s_pos + s_neg;
282 assert!(
283 (sum - 32768).abs() < TOLERANCE * 2,
284 "symétrie brisée pour x={}: sigmoid({})={} + sigmoid({})={} = {}",
285 x, -x, s_pos, x, s_neg, sum
286 );
287 }
288 }
289
290 #[test]
291 fn test_sigmoid_always_positive() {
292 // sigmoid(x) > 0 pour tout x
293 for x in [i16::MIN, -16384, -1, 0, 1, 16384, i16::MAX] {
294 assert!(sigmoid_q15(x) > 0, "sigmoid({}) doit être > 0", x);
295 }
296 }
297
298 #[test]
299 fn test_sigmoid_always_below_one() {
300 // sigmoid(x) < 1.0 (< 32768) → au max i16::MAX = 32767
301 // On cast en i32 pour que la comparaison ait du sens pour le compilateur
302 for x in [i16::MIN, -16384, -1, 0, 1, 16384, i16::MAX] {
303 assert!(
304 (sigmoid_q15(x) as i32) < 32768,
305 "sigmoid({}) doit être < 32768",
306 x
307 );
308 }
309 }
310
311 #[test]
312 fn test_sigmoid_monotone() {
313 // sigmoid est strictement croissante
314 let a = sigmoid_q15(-32768); // sigmoid(-1.0)
315 let b = sigmoid_q15(-16384); // sigmoid(-0.5)
316 let c = sigmoid_q15(0); // sigmoid(0.0)
317 let d = sigmoid_q15(16384); // sigmoid(0.5)
318 let e = sigmoid_q15(32767); // sigmoid(≈1.0)
319 assert!(a < b, "sigmoid(-1.0)={} < sigmoid(-0.5)={}", a, b);
320 assert!(b < c, "sigmoid(-0.5)={} < sigmoid(0.0)={}", b, c);
321 assert!(c < d, "sigmoid(0.0)={} < sigmoid(0.5)={}", c, d);
322 assert!(d < e, "sigmoid(0.5)={} < sigmoid(1.0)={}", d, e);
323 }
324
325 #[test]
326 fn test_sigmoid_min_no_panic() {
327 // i16::MIN est un cas limite : -i16::MIN déborde en i16
328 // On vérifie que ça ne panique pas et retourne quelque chose de valide
329 let res = sigmoid_q15(i16::MIN);
330 assert!(res > 0, "sigmoid(i16::MIN)={} doit être > 0", res);
331 }
332
333 #[test]
334 fn test_sigmoid_output_in_range() {
335 // Toute sortie doit être dans [1, 32767]
336 for x in [i16::MIN, -32000, -16384, -1, 0, 1, 16384, 32000, i16::MAX] {
337 let res = sigmoid_q15(x);
338 assert!(
339 res >= 1,
340 "sigmoid({})={} doit être >= 1",
341 x, res
342 );
343 }
344 }
345
346 //sigmoid_slice_q15
347
348 #[test]
349 fn test_slice_empty() {
350 let mut empty: [i16; 0] = [];
351 sigmoid_slice_q15(&mut empty);
352 }
353
354 #[test]
355 fn test_slice_zero_gives_half() {
356 let mut data = [0i16];
357 sigmoid_slice_q15(&mut data);
358 assert_eq!(data[0], 16384);
359 }
360
361 #[test]
362 fn test_slice_mixed() {
363 let mut data = [-32768i16, 0, 32767];
364 sigmoid_slice_q15(&mut data);
365 assert!(data[0] > 0 && data[0] < 16384); // < 0.5
366 assert_eq!(data[1], 16384); // = 0.5
367 assert!(data[2] > 16384); // > 0.5
368 }
369
370 #[test]
371 fn test_slice_all_positive_above_half() {
372 let mut data = [1i16, 8192, 16384, 32767];
373 sigmoid_slice_q15(&mut data);
374 for val in data {
375 assert!(val > 16384, "sigmoid positif doit être > 16384, reçu {}", val);
376 }
377 }
378
379 #[test]
380 fn test_slice_all_negative_below_half() {
381 let mut data = [-1i16, -8192, -16384, -32768];
382 sigmoid_slice_q15(&mut data);
383 for val in data {
384 assert!(val < 16384, "sigmoid négatif doit être < 16384, reçu {}", val);
385 }
386 }
387
388 #[test]
389 fn test_slice_idempotent_after_double_pass() {
390 // sigmoid n'est PAS idempotente (sigmoid(sigmoid(x)) != sigmoid(x))
391 // mais on vérifie que deux passes consécutives ne paniquent pas
392 // et restent dans la plage valide
393 let mut data = [-32768i16, -16384, 0, 16384, 32767];
394 sigmoid_slice_q15(&mut data);
395 sigmoid_slice_q15(&mut data);
396 for val in data {
397 assert!(val >= 1, "hors plage après double passe: {}", val);
398 }
399 }
400
401 #[test]
402 fn test_slice_boundary_values() {
403 let mut data = [i16::MIN, i16::MAX];
404 sigmoid_slice_q15(&mut data);
405 assert!(data[0] > 0 && data[0] < 16384);
406 assert!(data[1] > 16384);
407 }
408}