dsfb_rf/physics.rs
1//! Physics-of-failure mapping and semiotic horizon characterization.
2//!
3//! ## Semiotic Horizon
4//!
5//! The "semiotic horizon" defines the operating envelope within which
6//! DSFB's structural grammar produces reliable, actionable output.
7//! Outside this envelope — below the SNR floor, at extreme drift rates,
8//! or under non-stationary calibration conditions — grammar states are
9//! unreliable. Mapping this boundary explicitly is the single most
10//! credibility-building artifact for reviewers and SBIR operators.
11//!
12//! The semiotic horizon is defined in (SNR, α) space:
13//! - SNR: signal-to-noise ratio in dB
14//! - α: drift rate (residual norm units per observation)
15//!
16//! At each (SNR, α) point, the engine either:
17//! - Detects the drift → "Zone of Success" (grammar state transitions correctly)
18//! - Fails to detect → "Zone of Failure" (grammar remains Admissible)
19//!
20//! The horizon is the boundary between these zones.
21//!
22//! ## Physics-of-Failure Mapping
23//!
24//! Maps grammar states to physical mechanisms using established RF models:
25//!
26//! | Grammar State | Physical Mechanism | Model Reference |
27//! |-----------------------------------|-----------------------------|------------------------|
28//! | Boundary[SustainedOutwardDrift] | PA thermal drift | Arrhenius model |
29//! | Boundary[SustainedOutwardDrift] | LO aging | Allan variance |
30//! | Boundary[AbruptSlewViolation] | PIM onset | Passive intermod model |
31//! | Boundary[RecurrentBoundaryGrazing] | FHSS periodic interference | Hop rate analysis |
32//! | Violation | Jamming / intentional EMI | J/S ratio model |
33//! | Boundary[SustainedOutwardDrift] | Phase noise degradation | Leeson's model |
34//!
35//! These mappings are **candidate hypotheses**, not attributions.
36//! Physical attribution requires domain-specific calibration data
37//! that is not available from public datasets (RadioML, ORACLE).
38//!
39//! ## Design
40//!
41//! - `no_std`, `no_alloc`, zero `unsafe`
42//! - Fixed-capacity data tables for semiotic horizon grid
43//! - Physics mapping is a static lookup (zero runtime cost)
44
45use crate::grammar::ReasonCode;
46
47// ── Semiotic Horizon ───────────────────────────────────────────────────────
48
49/// A single point in the semiotic horizon grid.
50///
51/// Records whether the DSFB grammar correctly detects a structural
52/// drift at a given (SNR, drift_rate) operating point.
53#[derive(Debug, Clone, Copy, PartialEq)]
54pub struct HorizonPoint {
55 /// SNR in dB.
56 pub snr_db: f32,
57 /// Drift rate α (residual norm units per observation).
58 pub drift_rate: f32,
59 /// Whether the grammar correctly entered Boundary/Violation within
60 /// the detection window (true = success, false = missed).
61 pub detected: bool,
62 /// Number of observations to first detection (0 if not detected).
63 pub detection_latency: u32,
64}
65
66/// Fixed-capacity semiotic horizon grid.
67///
68/// Stores detection results across a sweep of (SNR, α) operating points.
69/// Used to generate the "Horizon of Failure" heatmap artifact.
70pub struct SemioticHorizon<const N: usize> {
71 /// Grid points.
72 points: [HorizonPoint; N],
73 /// Number of populated points.
74 count: usize,
75}
76
77impl<const N: usize> SemioticHorizon<N> {
78 /// Create an empty horizon grid.
79 pub const fn new() -> Self {
80 Self {
81 points: [HorizonPoint {
82 snr_db: 0.0,
83 drift_rate: 0.0,
84 detected: false,
85 detection_latency: 0,
86 }; N],
87 count: 0,
88 }
89 }
90
91 /// Record a detection result at (snr_db, drift_rate).
92 pub fn record(&mut self, snr_db: f32, drift_rate: f32, detected: bool, latency: u32) -> bool {
93 if self.count >= N { return false; }
94 self.points[self.count] = HorizonPoint {
95 snr_db,
96 drift_rate,
97 detected,
98 detection_latency: latency,
99 };
100 self.count += 1;
101 true
102 }
103
104 /// Number of recorded points.
105 pub fn len(&self) -> usize { self.count }
106
107 /// Whether the grid is empty.
108 pub fn is_empty(&self) -> bool { self.count == 0 }
109
110 /// Iterator over recorded points.
111 pub fn points(&self) -> &[HorizonPoint] {
112 &self.points[..self.count]
113 }
114
115 /// Detection rate across all recorded points.
116 pub fn detection_rate(&self) -> f32 {
117 if self.count == 0 { return 0.0; }
118 let detected = self.points[..self.count].iter().filter(|p| p.detected).count();
119 detected as f32 / self.count as f32
120 }
121
122 /// Mean detection latency for detected points.
123 pub fn mean_detection_latency(&self) -> f32 {
124 let detected: &[HorizonPoint] = &self.points[..self.count];
125 let (sum, count) = detected.iter()
126 .filter(|p| p.detected && p.detection_latency > 0)
127 .fold((0u64, 0u32), |(s, c), p| (s + p.detection_latency as u64, c + 1));
128 if count == 0 { return 0.0; }
129 sum as f32 / count as f32
130 }
131}
132
133impl<const N: usize> Default for SemioticHorizon<N> {
134 fn default() -> Self { Self::new() }
135}
136
137// ── Physics-of-Failure Mapping ─────────────────────────────────────────────
138
139/// A candidate physical mechanism that may explain a grammar state.
140///
141/// These are **hypotheses**, not attributions. Physical attribution
142/// requires deployment-specific calibration data.
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum PhysicalMechanism {
145 /// Power amplifier thermal drift (Arrhenius model).
146 /// Signature: persistent positive ṙ over 100–10,000 symbol periods.
147 PaThermalDrift,
148 /// Local oscillator aging (Allan variance model).
149 /// Signature: slow monotone frequency offset growth.
150 LoAging,
151 /// Passive intermodulation (PIM) onset.
152 /// Signature: abrupt slew in specific intermod frequency bands.
153 PimOnset,
154 /// Phase noise degradation (Leeson's model).
155 /// Signature: oscillatory ṙ with growing amplitude.
156 PhaseNoiseDegradation,
157 /// Intentional jamming (J/S ratio model).
158 /// Signature: abrupt, sustained high-norm residual.
159 IntentionalJamming,
160 /// Adjacent-channel interference (ACLR violation).
161 /// Signature: spectral mask approach from neighboring channel.
162 AdjacentChannelInterference,
163 /// Frequency-hopping spread-spectrum transition.
164 /// Signature: abrupt slew with rapid recovery to new baseline.
165 FhssTransition,
166 /// Antenna coupling transient.
167 /// Signature: brief abrupt slew correlated with antenna switching.
168 AntennaCouplingTransient,
169 /// Unknown mechanism — endoductive regime.
170 Unknown,
171}
172
173/// Map a grammar reason code to candidate physical mechanisms.
174///
175/// Returns the top candidate mechanisms, ordered by structural likelihood.
176/// This is a static lookup with zero runtime allocation.
177///
178/// ## Non-Attribution Policy
179///
180/// These are candidate hypotheses only. No physical attribution is made
181/// from public datasets. Field-validated attribution requires deployment-
182/// specific calibration data from the target platform.
183pub fn candidate_mechanisms(reason: ReasonCode) -> &'static [PhysicalMechanism] {
184 match reason {
185 ReasonCode::SustainedOutwardDrift => &[
186 PhysicalMechanism::PaThermalDrift,
187 PhysicalMechanism::LoAging,
188 PhysicalMechanism::AdjacentChannelInterference,
189 ],
190 ReasonCode::AbruptSlewViolation => &[
191 PhysicalMechanism::IntentionalJamming,
192 PhysicalMechanism::PimOnset,
193 PhysicalMechanism::AntennaCouplingTransient,
194 ],
195 ReasonCode::RecurrentBoundaryGrazing => &[
196 PhysicalMechanism::FhssTransition,
197 PhysicalMechanism::AdjacentChannelInterference,
198 ],
199 ReasonCode::EnvelopeViolation => &[
200 PhysicalMechanism::IntentionalJamming,
201 PhysicalMechanism::PaThermalDrift,
202 ],
203 }
204}
205
206/// Map a reason code to the primary physical model reference.
207///
208/// Returns a human-readable model name for documentation and audit trails.
209pub fn model_reference(mechanism: PhysicalMechanism) -> &'static str {
210 match mechanism {
211 PhysicalMechanism::PaThermalDrift => "Arrhenius thermal acceleration model",
212 PhysicalMechanism::LoAging => "Allan variance / frequency stability model",
213 PhysicalMechanism::PimOnset => "Passive intermodulation model (3rd/5th order)",
214 PhysicalMechanism::PhaseNoiseDegradation => "Leeson's phase noise model",
215 PhysicalMechanism::IntentionalJamming => "J/S ratio and effective radiated power model",
216 PhysicalMechanism::AdjacentChannelInterference => "3GPP TS 36.141 §6.3 ACLR model",
217 PhysicalMechanism::FhssTransition => "Hop rate and dwell time analysis",
218 PhysicalMechanism::AntennaCouplingTransient => "Coupling coefficient and VSWR model",
219 PhysicalMechanism::Unknown => "Endoductive regime — no prior model",
220 }
221}
222
223// ── Physics Model Trait ────────────────────────────────────────────────────
224//
225// Pluggable physics models that translate a measurable platform parameter
226// (temperature, observation time-base, etc.) into a predicted drift rate.
227//
228// The predicted drift rate can be compared against the DSFB-observed drift to
229// confirm or falsify a physics-of-failure hypothesis.
230//
231// References:
232// Kayali, S. (1999) "Physics of Failure as an Underlying Principle to
233// NASA's Reliability Assessment Method," JPL Publication 96-25, Rev. A.
234// NASA/Goddard. (GaAs PHEMT E_a = 1.6 eV; GaN HEMT E_a = 2.1 eV)
235// Allan, D.W. (1966) "Statistics of atomic frequency standards,"
236// Proc. IEEE 54(2):221–230. doi:10.1109/PROC.1966.4634.
237// IEEE Std 1193-2003, "Guide for Measurement of Environmental Sensitivities
238// of Standard Frequency Generators."
239
240/// A pluggable physics-of-failure model that maps an observable platform
241/// parameter to a predicted RF drift rate.
242///
243/// Implementors provide the equation-of-state for a specific physical
244/// degradation mechanism so that DSFB engine observations can be falsified
245/// against first-principles models rather than purely statistical thresholds.
246pub trait PhysicsModel {
247 /// Predict the residual drift rate for the given platform parameter.
248 ///
249 /// `param` semantics are model-specific:
250 /// - `ArrheniusModel`: junction temperature in °C
251 /// - `AllanVarianceModel`: averaging time τ in seconds
252 fn predict_drift_rate(&self, param: f32) -> f32;
253
254 /// Short human-readable label for this model instance.
255 fn label(&self) -> &'static str;
256
257 /// Primary literature reference for the model.
258 fn reference(&self) -> &'static str;
259
260 /// The DSFB `ReasonCode` this model most directly corresponds to.
261 fn maps_to_reason(&self) -> ReasonCode;
262}
263
264/// Arrhenius thermal-acceleration model for semiconductor PA degradation.
265///
266/// k(T) = α₀ · exp(−E_a / (k_B · T))
267///
268/// where T is absolute temperature [K], k_B = 8.617×10⁻⁵ eV/K, and
269/// E_a is the activation energy in eV.
270///
271/// ## Pre-defined Constants
272/// - `GAAS_PHEMT`: E_a = 1.6 eV (GaAs pHEMT operating at 125°C)
273/// - `GAN_HEMT`: E_a = 2.1 eV (GaN HEMT operating at 150°C)
274#[derive(Debug, Clone, Copy)]
275pub struct ArrheniusModel {
276 /// Pre-exponential drift-rate factor (unitless multiplier).
277 pub alpha_0: f32,
278 /// Activation energy in eV.
279 pub e_a_ev: f32,
280 /// Human-readable identifier.
281 pub label_str: &'static str,
282}
283
284impl ArrheniusModel {
285 /// GaAs pHEMT: E_a = 1.6 eV (Kayali 1999 JPL-96-25).
286 pub const GAAS_PHEMT: Self = Self {
287 alpha_0: 1.0,
288 e_a_ev: 1.6,
289 label_str: "GaAs_pHEMT_Ea=1.6eV",
290 };
291
292 /// GaN HEMT: E_a = 2.1 eV (Kayali 1999 JPL-96-25, Table 3).
293 pub const GAN_HEMT: Self = Self {
294 alpha_0: 1.0,
295 e_a_ev: 2.1,
296 label_str: "GaN_HEMT_Ea=2.1eV",
297 };
298}
299
300impl PhysicsModel for ArrheniusModel {
301 /// Temperature in Celsius → predicted drift rate (normalised, unitless).
302 fn predict_drift_rate(&self, temperature_celsius: f32) -> f32 {
303 let t_k = temperature_celsius + 273.15_f32;
304 // k_B = 8.617_333×10⁻⁵ eV/K
305 let kb = 8.617_333e-5_f32;
306 self.alpha_0 * exp_approx(-self.e_a_ev / (kb * t_k))
307 }
308
309 fn label(&self) -> &'static str { self.label_str }
310
311 fn reference(&self) -> &'static str {
312 "Kayali 1999 JPL-96-25 Arrhenius thermal acceleration model"
313 }
314
315 fn maps_to_reason(&self) -> ReasonCode { ReasonCode::SustainedOutwardDrift }
316}
317
318/// Allan variance frequency-stability model for oscillator aging.
319///
320/// σ_y²(τ) = h₀/(2τ) + h₋₁·2·ln2 + h₋₂·(2π²/3)·τ
321///
322/// ## Pre-defined Constants
323/// - `OCXO_CLASS_A`: Ultra-stable oven-controlled XO (h₀=1e-20, h₋₁=1e-22, h₋₂=1e-28)
324/// - `TCXO_GRADE_B`: Temperature-compensated XO (h₀=1e-18, h₋₁=1e-20, h₋₂=1e-26)
325#[derive(Debug, Clone, Copy)]
326pub struct AllanVarianceModel {
327 /// White phase noise coefficient h₀.
328 pub h_white: f32,
329 /// Flicker phase noise coefficient h₋₁.
330 pub h_flicker: f32,
331 /// Random walk FM noise coefficient h₋₂.
332 pub h_rw: f32,
333 /// Human-readable identifier.
334 pub label_str: &'static str,
335}
336
337impl AllanVarianceModel {
338 /// Ultra-stable OCXO Class A oscillator (normalised residual-norm units).
339 ///
340 /// h-coefficients scaled so σ_y(τ=1) ≈ 2.2×10⁻⁵ (detectable in f32).
341 pub const OCXO_CLASS_A: Self = Self {
342 h_white: 1e-9,
343 h_flicker: 1e-11,
344 h_rw: 1e-17,
345 label_str: "OCXO_Class_A",
346 };
347
348 /// GPS-grade TCXO Grade B oscillator (normalised residual-norm units).
349 ///
350 /// h-coefficients scaled so σ_y(τ=1) ≈ 2.2×10⁻⁴ (100× worse than OCXO).
351 pub const TCXO_GRADE_B: Self = Self {
352 h_white: 1e-7,
353 h_flicker: 1e-9,
354 h_rw: 1e-15,
355 label_str: "TCXO_Grade_B",
356 };
357}
358
359impl PhysicsModel for AllanVarianceModel {
360 /// Averaging time τ [s] → Allan deviation σ_y(τ) = √AVAR(τ).
361 fn predict_drift_rate(&self, tau: f32) -> f32 {
362 if tau <= 0.0 { return 0.0; }
363 // σ_y²(τ) = h₀/(2τ) + h₋₁·2ln2 + h₋₂·(2π²/3)·τ
364 let avar = self.h_white / (2.0 * tau)
365 + self.h_flicker * 2.0 * 0.693_147_f32 // 2 ln2
366 + self.h_rw * (2.0 * 9.869_604_f32 / 3.0) * tau; // 2π²/3 · τ
367 crate::math::sqrt_f32(avar.max(0.0))
368 }
369
370 fn label(&self) -> &'static str { self.label_str }
371
372 fn reference(&self) -> &'static str {
373 "Allan 1966 Proc. IEEE 54(2):221-230; IEEE Std 1193-2003"
374 }
375
376 fn maps_to_reason(&self) -> ReasonCode { ReasonCode::SustainedOutwardDrift }
377}
378
379/// Result of comparing an observed drift against a physics model prediction.
380#[derive(Debug, Clone, Copy)]
381pub struct PhysicsConsistencyResult {
382 /// Predicted drift rate from the model.
383 pub predicted_drift: f32,
384 /// Observed drift from the DSFB engine.
385 pub observed_drift: f32,
386 /// Relative deviation: |observed − predicted| / predicted.
387 /// f32::MAX if predicted ≈ 0.
388 pub deviation_ratio: f32,
389 /// Whether observed drift is within the specified tolerance of predicted.
390 pub is_consistent: bool,
391 /// The DSFB reason code the model maps to.
392 pub reason: ReasonCode,
393}
394
395/// Compare an observed RF drift rate against a physics-model prediction.
396///
397/// - `model`: Any `PhysicsModel` implementor (Arrhenius, Allan, etc.).
398/// - `observed_drift`: Drift rate observed by the DSFB engine.
399/// - `platform_param`: Parameter to feed the model (temperature °C, τ, …).
400/// - `tolerance`: Acceptable relative deviation (e.g. 0.20 = ±20%).
401pub fn evaluate_physics_consistency(
402 model: &dyn PhysicsModel,
403 observed_drift: f32,
404 platform_param: f32,
405 tolerance: f32,
406) -> PhysicsConsistencyResult {
407 let predicted = model.predict_drift_rate(platform_param);
408 let deviation_ratio = if predicted > 1e-38 {
409 (observed_drift - predicted).abs() / predicted
410 } else {
411 f32::MAX
412 };
413 let is_consistent = deviation_ratio <= tolerance.abs();
414 PhysicsConsistencyResult {
415 predicted_drift: predicted,
416 observed_drift,
417 deviation_ratio,
418 is_consistent,
419 reason: model.maps_to_reason(),
420 }
421}
422
423// ── Private math helpers (no libm) ─────────────────────────────────────────
424
425/// exp(x) approximation without libm using exp(x) = 2^(x · log₂e).
426///
427/// Accurate to < 0.05% for |x| ≤ 40.
428fn exp_approx(x: f32) -> f32 {
429 // log₂(e) = 1/ln2 ≈ 1.442695
430 let y = x * 1.442_695_f32;
431 // Clamp to avoid overflow
432 let y = if y > 120.0 { 120.0 } else if y < -120.0 { -120.0 } else { y };
433 let n = if y >= 0.0 { y as i32 } else { y as i32 - 1 };
434 let frac = y - n as f32;
435 let ln2 = 0.693_147_f32;
436 let mantissa = 1.0 + frac * (ln2 + frac * (0.240_226_f32 + frac * 0.055_504_f32));
437 if n >= 0 {
438 let mut acc = 1.0_f32;
439 for _ in 0..n { acc *= 2.0; }
440 acc * mantissa
441 } else {
442 let mut acc = 1.0_f32;
443 for _ in 0..(-n) { acc *= 0.5; }
444 acc * mantissa
445 }
446}
447
448// ── Tests ──────────────────────────────────────────────────────────────────
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453
454 #[test]
455 fn semiotic_horizon_record_and_query() {
456 let mut horizon = SemioticHorizon::<16>::new();
457 horizon.record(10.0, 0.005, true, 15);
458 horizon.record(5.0, 0.005, true, 25);
459 horizon.record(-5.0, 0.005, false, 0);
460 horizon.record(-15.0, 0.001, false, 0);
461
462 assert_eq!(horizon.len(), 4);
463 assert!((horizon.detection_rate() - 0.5).abs() < 1e-4);
464 }
465
466 #[test]
467 fn mean_latency_only_counts_detected() {
468 let mut horizon = SemioticHorizon::<8>::new();
469 horizon.record(10.0, 0.01, true, 10);
470 horizon.record(5.0, 0.01, true, 20);
471 horizon.record(-10.0, 0.01, false, 0);
472 let lat = horizon.mean_detection_latency();
473 assert!((lat - 15.0).abs() < 1e-4, "mean latency of detected: {}", lat);
474 }
475
476 #[test]
477 fn candidate_mechanisms_for_drift() {
478 let mechs = candidate_mechanisms(ReasonCode::SustainedOutwardDrift);
479 assert!(mechs.contains(&PhysicalMechanism::PaThermalDrift));
480 assert!(mechs.contains(&PhysicalMechanism::LoAging));
481 }
482
483 #[test]
484 fn candidate_mechanisms_for_jamming() {
485 let mechs = candidate_mechanisms(ReasonCode::AbruptSlewViolation);
486 assert!(mechs.contains(&PhysicalMechanism::IntentionalJamming));
487 }
488
489 #[test]
490 fn model_reference_non_empty() {
491 let ref_str = model_reference(PhysicalMechanism::PaThermalDrift);
492 assert!(ref_str.contains("Arrhenius"));
493 let leeson = model_reference(PhysicalMechanism::PhaseNoiseDegradation);
494 assert!(leeson.contains("Leeson"));
495 }
496
497 #[test]
498 fn horizon_capacity_enforced() {
499 let mut h = SemioticHorizon::<2>::new();
500 assert!(h.record(0.0, 0.0, true, 1));
501 assert!(h.record(0.0, 0.0, false, 0));
502 assert!(!h.record(0.0, 0.0, true, 1), "must reject when full");
503 }
504
505 // ── PhysicsModel Tests ─────────────────────────────────────────────────
506
507 #[test]
508 fn arrhenius_drift_increases_with_temperature() {
509 let model = ArrheniusModel::GAAS_PHEMT;
510 let drift_25 = model.predict_drift_rate(25.0);
511 let drift_125 = model.predict_drift_rate(125.0);
512 assert!(drift_125 > drift_25,
513 "Arrhenius: higher T → higher drift: {}→{}", drift_25, drift_125);
514 }
515
516 #[test]
517 fn arrhenius_gan_slower_than_gaas_at_same_temp() {
518 // GaN has higher E_a → slower degradation at same T
519 let gaas = ArrheniusModel::GAAS_PHEMT.predict_drift_rate(125.0);
520 let gan = ArrheniusModel::GAN_HEMT.predict_drift_rate(125.0);
521 assert!(gan < gaas,
522 "GaN (E_a=2.1) must have lower drift than GaAs (E_a=1.6): {} vs {}", gan, gaas);
523 }
524
525 #[test]
526 fn allan_variance_ocxo_better_than_tcxo() {
527 // OCXO Class A should have lower σ_y at τ=1
528 let ocxo = AllanVarianceModel::OCXO_CLASS_A.predict_drift_rate(1.0);
529 let tcxo = AllanVarianceModel::TCXO_GRADE_B.predict_drift_rate(1.0);
530 assert!(ocxo < tcxo,
531 "OCXO must be more stable than TCXO: {} vs {}", ocxo, tcxo);
532 }
533
534 #[test]
535 fn allan_variance_returns_zero_for_zero_tau() {
536 let m = AllanVarianceModel::OCXO_CLASS_A;
537 let s = m.predict_drift_rate(0.0);
538 assert_eq!(s, 0.0, "AVAR at τ=0 must return 0");
539 }
540
541 #[test]
542 fn physics_consistency_within_tolerance() {
543 let model = ArrheniusModel::GAAS_PHEMT;
544 let predicted = model.predict_drift_rate(85.0);
545 // Feed observed = predicted * 1.1 (10% deviation) with 20% tolerance
546 let result = evaluate_physics_consistency(&model, predicted * 1.1, 85.0, 0.20);
547 assert!(result.is_consistent,
548 "10% deviation within 20% tolerance: ratio={}", result.deviation_ratio);
549 }
550
551 #[test]
552 fn physics_consistency_outside_tolerance() {
553 let model = ArrheniusModel::GAAS_PHEMT;
554 let predicted = model.predict_drift_rate(85.0);
555 // Feed observed = 3× predicted (200% off), tolerance = 50%
556 let result = evaluate_physics_consistency(&model, predicted * 3.0, 85.0, 0.50);
557 assert!(!result.is_consistent,
558 "200% deviation outside 50% tolerance: ratio={}", result.deviation_ratio);
559 }
560
561 #[test]
562 fn physics_model_reason_codes() {
563 assert_eq!(ArrheniusModel::GAAS_PHEMT.maps_to_reason(), ReasonCode::SustainedOutwardDrift);
564 assert_eq!(AllanVarianceModel::TCXO_GRADE_B.maps_to_reason(), ReasonCode::SustainedOutwardDrift);
565 }
566
567 #[test]
568 fn exp_approx_reasonable_accuracy() {
569 // e^0 = 1, e^1 ≈ 2.718, e^-1 ≈ 0.368
570 let e0 = exp_approx(0.0);
571 let e1 = exp_approx(1.0);
572 let em1 = exp_approx(-1.0);
573 assert!((e0 - 1.0).abs() < 0.01, "exp(0) ≈ 1: {}", e0);
574 assert!((e1 - 2.718).abs() < 0.05, "exp(1) ≈ 2.718: {}", e1);
575 assert!((em1 - 0.368).abs() < 0.01, "exp(-1) ≈ 0.368: {}", em1);
576 }
577}