Skip to main content

fin_stream/lorentz/
mod.rs

1//! Lorentz (special-relativistic) transforms applied to financial time series.
2//!
3//! ## Mathematical basis
4//!
5//! In special relativity, the Lorentz transformation relates the spacetime
6//! coordinates `(t, x)` measured in one inertial frame `S` to the coordinates
7//! `(t', x')` measured in a frame `S'` that moves with velocity `v` along the
8//! x-axis:
9//!
10//! ```text
11//!     t' = gamma * (t  - beta * x / c)
12//!     x' = gamma * (x  - beta * c * t)
13//!
14//!     where  beta  = v / c          (dimensionless velocity, 0 <= beta < 1)
15//!            gamma = 1 / sqrt(1 - beta^2)   (Lorentz factor, >= 1)
16//! ```
17//!
18//! ## Application to market data
19//!
20//! Market microstructure theory frequently treats price discovery as a
21//! diffusion process in two dimensions:
22//!
23//! - **Time axis `t`**: wall-clock time (seconds, or normalized to [0, 1]).
24//! - **Price axis `x`**: log-price or normalized price.
25//!
26//! The Lorentz frame boost is applied as a **feature engineering** step:
27//! it stretches or compresses the price-time plane along hyperbolas, which
28//! are invariant under Lorentz boosts. The motivation is that certain
29//! microstructure signals (momentum, mean-reversion) that appear curved in
30//! the lab frame can appear as straight lines in a boosted frame.
31//!
32//! For a financial series, we set:
33//! - `c = 1` (price changes are bounded by some maximum instantaneous return).
34//! - `beta = drift_rate` (the normalized drift velocity of the price process).
35//!
36//! The transformed coordinates `(t', x')` are then fed as features into a
37//! downstream model.
38//!
39//! ## Guarantees
40//!
41//! - Non-panicking: construction validates `beta` and returns a typed error.
42//! - Deterministic: the same `(t, x, beta)` always produces the same output.
43//! - `beta = 0` is the identity transform (gamma = 1, no distortion).
44
45use crate::error::StreamError;
46
47/// A spacetime event expressed as a (time, position) coordinate pair.
48///
49/// In the financial interpretation:
50/// - `t` is elapsed time since the series start, normalized to a convenient
51///   scale (e.g., seconds, or fraction of the session).
52/// - `x` is normalized log-price or normalized price.
53#[derive(Debug, Clone, Copy, PartialEq)]
54pub struct SpacetimePoint {
55    /// Time coordinate.
56    pub t: f64,
57    /// Spatial (price) coordinate.
58    pub x: f64,
59}
60
61impl SpacetimePoint {
62    /// Construct a new spacetime point.
63    pub fn new(t: f64, x: f64) -> Self {
64        Self { t, x }
65    }
66
67    /// 4-displacement from `self` to `other`: `(Δt, Δx) = (other.t - self.t, other.x - self.x)`.
68    ///
69    /// Useful for computing proper distances and intervals between events.
70    pub fn displacement(self, other: Self) -> Self {
71        Self {
72            t: other.t - self.t,
73            x: other.x - self.x,
74        }
75    }
76
77    /// Minkowski norm squared: `t² − x²`.
78    ///
79    /// The sign classifies the causal relationship of the event to the origin:
80    /// - Positive: time-like (inside the light cone — causally reachable).
81    /// - Zero: light-like / null (on the light cone).
82    /// - Negative: space-like (outside the light cone — causally disconnected).
83    pub fn norm_sq(self) -> f64 {
84        self.t * self.t - self.x * self.x
85    }
86
87    /// Returns `true` if this point is inside the light cone (`t² > x²`).
88    pub fn is_timelike(self) -> bool {
89        self.norm_sq() > 0.0
90    }
91
92    /// Returns `true` if this point lies on the light cone.
93    ///
94    /// Uses a tolerance of `1e-10` for floating-point comparisons.
95    pub fn is_lightlike(self) -> bool {
96        self.norm_sq().abs() < 1e-10
97    }
98
99    /// Returns `true` if this point is outside the light cone (`t² < x²`).
100    pub fn is_spacelike(self) -> bool {
101        self.norm_sq() < 0.0
102    }
103
104    /// Proper spacetime distance between `self` and `other`.
105    ///
106    /// Computed as `sqrt(|Δt² − Δx²|)` where `Δ = other − self`. The absolute
107    /// value ensures a real result regardless of the interval type.
108    /// For time-like separations this is the proper time; for space-like
109    /// separations it is the proper length.
110    pub fn distance_to(self, other: Self) -> f64 {
111        let d = self.displacement(other);
112        d.norm_sq().abs().sqrt()
113    }
114}
115
116/// Lorentz frame-boost transform for financial time series.
117///
118/// Applies the special-relativistic Lorentz transformation with velocity
119/// parameter `beta = v/c` to `(t, x)` coordinate pairs. The speed of light
120/// is normalized to `c = 1`.
121///
122/// # Construction
123///
124/// ```rust
125/// use fin_stream::lorentz::LorentzTransform;
126///
127/// let lt = LorentzTransform::new(0.5).unwrap(); // beta = 0.5
128/// ```
129///
130/// # Identity
131///
132/// `beta = 0` is the identity: `t' = t`, `x' = x`.
133///
134/// # Singularity avoidance
135///
136/// `beta >= 1` would require dividing by zero in the Lorentz factor and is
137/// rejected at construction time with `LorentzConfigError`.
138#[derive(Debug, Clone, Copy)]
139pub struct LorentzTransform {
140    /// Normalized velocity (v/c). Satisfies `0.0 <= beta < 1.0`.
141    beta: f64,
142    /// Precomputed Lorentz factor: `1 / sqrt(1 - beta^2)`.
143    gamma: f64,
144}
145
146impl LorentzTransform {
147    /// Create a new Lorentz transform with the given velocity parameter.
148    ///
149    /// `beta` must satisfy `0.0 <= beta < 1.0`.
150    ///
151    /// # Errors
152    ///
153    /// Returns `LorentzConfigError` if `beta` is negative, `NaN`, or `>= 1.0`.
154    pub fn new(beta: f64) -> Result<Self, StreamError> {
155        if beta.is_nan() || beta < 0.0 || beta >= 1.0 {
156            return Err(StreamError::LorentzConfigError {
157                reason: format!(
158                    "beta must be in [0.0, 1.0) but got {beta}; \
159                     beta >= 1 produces a division by zero in the Lorentz factor"
160                ),
161            });
162        }
163        let gamma = 1.0 / (1.0 - beta * beta).sqrt();
164        Ok(Self { beta, gamma })
165    }
166
167    /// Compute beta from a known gamma value.
168    ///
169    /// Inverts `gamma = 1 / sqrt(1 - beta^2)` to recover `beta = sqrt(1 - 1/gamma^2)`.
170    /// Useful when a Lorentz factor is known from a source other than a velocity.
171    ///
172    /// # Errors
173    ///
174    /// Returns [`StreamError::LorentzConfigError`] if `gamma < 1.0` or is `NaN`
175    /// (gamma must be ≥ 1 for a physically valid boost).
176    /// Construct a Lorentz transform from rapidity `η = atanh(β)`.
177    ///
178    /// Rapidity is the additive parameter for successive boosts along the
179    /// same axis: composing two boosts `η₁` and `η₂` gives `η₁ + η₂`.
180    ///
181    /// # Errors
182    ///
183    /// Returns [`StreamError::LorentzConfigError`] if `eta` is `NaN`, `Inf`, or
184    /// results in `|beta| >= 1`.
185    pub fn from_rapidity(eta: f64) -> Result<Self, StreamError> {
186        if !eta.is_finite() {
187            return Err(StreamError::LorentzConfigError {
188                reason: format!("rapidity must be finite, got {eta}"),
189            });
190        }
191        let beta = eta.tanh();
192        Self::new(beta)
193    }
194
195    /// Construct a [`LorentzTransform`] from a velocity `v` and speed of light `c`.
196    ///
197    /// Computes `beta = v / c` and delegates to [`Self::new`].
198    ///
199    /// # Errors
200    ///
201    /// Returns [`StreamError::LorentzConfigError`] if `c` is zero, if `v` or
202    /// `c` are non-finite, or if the resulting `|beta| >= 1`.
203    pub fn from_velocity(v: f64, c: f64) -> Result<Self, StreamError> {
204        if !v.is_finite() || !c.is_finite() {
205            return Err(StreamError::LorentzConfigError {
206                reason: format!("v and c must be finite, got v={v}, c={c}"),
207            });
208        }
209        if c == 0.0 {
210            return Err(StreamError::LorentzConfigError {
211                reason: "speed of light c must be non-zero".into(),
212            });
213        }
214        Self::new(v / c)
215    }
216
217    /// Returns the Lorentz gamma factor for the given `beta`: `1 / √(1 − β²)`.
218    ///
219    /// This is a pure mathematical helper — no validation is performed.
220    /// Returns `f64::INFINITY` when `beta == 1.0` and `NaN` when `beta > 1.0`.
221    pub fn gamma_at(beta: f64) -> f64 {
222        (1.0 - beta * beta).sqrt().recip()
223    }
224
225    /// Relativistic momentum: `m × γ × β` (in natural units where `c = 1`).
226    ///
227    /// Returns the momentum of a particle with rest mass `mass` moving at
228    /// the velocity encoded in this transform. Negative mass is allowed for
229    /// analytical purposes but physically meaningless.
230    pub fn relativistic_momentum(&self, mass: f64) -> f64 {
231        mass * self.gamma * self.beta
232    }
233
234    /// Kinetic energy factor: `γ − 1`.
235    ///
236    /// The relativistic kinetic energy is `KE = (γ − 1) mc²`. This method
237    /// returns the dimensionless factor `γ − 1` so callers can multiply by
238    /// `mc²` in their own units. Zero at rest (`β = 0`).
239    pub fn kinetic_energy_ratio(&self) -> f64 {
240        self.gamma - 1.0
241    }
242
243    /// Time dilation factor: `γ` (gamma).
244    ///
245    /// Equivalent to [`gamma`](Self::gamma) but named for clarity when used in
246    /// a time-dilation context. A moving clock ticks slower by this factor
247    /// relative to the stationary frame.
248    pub fn time_dilation_factor(&self) -> f64 {
249        self.gamma
250    }
251
252    /// Length contraction factor: `L' = L / gamma`.
253    ///
254    /// The reciprocal of the Lorentz factor — always in `(0.0, 1.0]`.
255    pub fn length_contraction_factor(&self) -> f64 {
256        1.0 / self.gamma
257    }
258
259    /// Computes beta (v/c) from a Lorentz gamma factor. Returns an error if `gamma < 1.0`.
260    pub fn beta_from_gamma(gamma: f64) -> Result<f64, StreamError> {
261        if gamma.is_nan() || gamma < 1.0 {
262            return Err(StreamError::LorentzConfigError {
263                reason: format!("gamma must be >= 1.0, got {gamma}"),
264            });
265        }
266        Ok((1.0 - 1.0 / (gamma * gamma)).sqrt())
267    }
268
269    /// Rapidity: `φ = atanh(β)`.
270    ///
271    /// Unlike velocity, rapidities add linearly under boosts:
272    /// `φ_total = φ_1 + φ_2`.
273    pub fn rapidity(&self) -> f64 {
274        self.beta.atanh()
275    }
276
277    /// Relativistic parameter `β·γ` — proportional to the particle's 3-momentum.
278    ///
279    /// Defined as `β * γ = β / √(1 - β²)`. Useful for comparing relativistic
280    /// momenta of different particles without specifying rest mass.
281    pub fn beta_times_gamma(&self) -> f64 {
282        self.beta * self.gamma
283    }
284
285    /// Converts a rapidity value back to a velocity `β = tanh(η)`.
286    ///
287    /// This is the inverse of `rapidity()`. Always returns a value in `(-1, 1)`.
288    pub fn beta_from_rapidity(eta: f64) -> f64 {
289        eta.tanh()
290    }
291
292    /// Proper velocity: `w = β × γ`.
293    ///
294    /// Unlike coordinate velocity `β`, proper velocity is unbounded and
295    /// equals the spatial displacement per unit proper time.
296    pub fn proper_velocity(&self) -> f64 {
297        self.beta * self.gamma
298    }
299
300    /// The configured velocity parameter `beta = v/c`.
301    pub fn beta(&self) -> f64 {
302        self.beta
303    }
304
305    /// The precomputed Lorentz factor `gamma = 1 / sqrt(1 - beta^2)`.
306    ///
307    /// `gamma == 1.0` when `beta == 0` (identity). `gamma` increases
308    /// monotonically towards infinity as `beta` approaches 1.
309    pub fn gamma(&self) -> f64 {
310        self.gamma
311    }
312
313    /// Apply the Lorentz boost to a spacetime point.
314    ///
315    /// Computes:
316    /// ```text
317    ///     t' = gamma * (t - beta * x)
318    ///     x' = gamma * (x - beta * t)
319    /// ```
320    ///
321    /// # Complexity: O(1), no heap allocation.
322    #[must_use]
323    pub fn transform(&self, p: SpacetimePoint) -> SpacetimePoint {
324        let t_prime = self.gamma * (p.t - self.beta * p.x);
325        let x_prime = self.gamma * (p.x - self.beta * p.t);
326        SpacetimePoint {
327            t: t_prime,
328            x: x_prime,
329        }
330    }
331
332    /// Apply the inverse Lorentz boost (boost in the opposite direction).
333    ///
334    /// Computes:
335    /// ```text
336    ///     t' = gamma * (t + beta * x)
337    ///     x' = gamma * (x + beta * t)
338    /// ```
339    ///
340    /// For a point `p`, `inverse_transform(transform(p)) == p` up to floating-
341    /// point rounding.
342    ///
343    /// # Complexity: O(1), no heap allocation.
344    #[must_use]
345    pub fn inverse_transform(&self, p: SpacetimePoint) -> SpacetimePoint {
346        let t_orig = self.gamma * (p.t + self.beta * p.x);
347        let x_orig = self.gamma * (p.x + self.beta * p.t);
348        SpacetimePoint {
349            t: t_orig,
350            x: x_orig,
351        }
352    }
353
354    /// Apply the boost to a batch of points.
355    ///
356    /// Returns a new `Vec` of transformed points. The input slice is not
357    /// modified.
358    ///
359    /// # Complexity: O(n), one heap allocation of size `n`.
360    pub fn transform_batch(&self, points: &[SpacetimePoint]) -> Vec<SpacetimePoint> {
361        points.iter().map(|&p| self.transform(p)).collect()
362    }
363
364    /// Apply the inverse boost to a batch of points.
365    ///
366    /// Equivalent to calling [`inverse_transform`](Self::inverse_transform) on each element.
367    /// For any slice `pts`, `inverse_transform_batch(&transform_batch(pts))` equals `pts`
368    /// up to floating-point rounding.
369    ///
370    /// # Complexity: O(n), one heap allocation of size `n`.
371    pub fn inverse_transform_batch(&self, points: &[SpacetimePoint]) -> Vec<SpacetimePoint> {
372        points.iter().map(|&p| self.inverse_transform(p)).collect()
373    }
374
375    /// Time-dilation: the transformed time coordinate for a point at rest
376    /// (`x = 0`) is `t' = gamma * t`.
377    ///
378    /// This is a convenience method that surfaces the time-dilation formula
379    /// for the common case where only the time axis is of interest.
380    ///
381    /// # Complexity: O(1)
382    pub fn dilate_time(&self, t: f64) -> f64 {
383        self.gamma * t
384    }
385
386    /// Length-contraction: the transformed spatial coordinate for an event at
387    /// the time origin (`t = 0`) is `x' = gamma * x`.
388    ///
389    /// This is a convenience method that surfaces the length-contraction
390    /// formula for the common case where only the price axis is of interest.
391    ///
392    /// # Complexity: O(1)
393    pub fn contract_length(&self, x: f64) -> f64 {
394        self.gamma * x
395    }
396
397    /// Proper time: `coordinate_time / gamma`.
398    ///
399    /// The inverse of [`dilate_time`](Self::dilate_time). Converts a dilated
400    /// coordinate time back to the proper time measured by a clock co-moving
401    /// with the boosted frame — always shorter than the coordinate time by
402    /// a factor of `gamma`.
403    ///
404    /// # Complexity: O(1)
405    pub fn time_contraction(&self, coordinate_time: f64) -> f64 {
406        coordinate_time / self.gamma
407    }
408
409    /// Returns `true` if this boost is ultrarelativistic (`beta > 0.9`).
410    ///
411    /// At `beta > 0.9` the Lorentz factor `gamma > 2.3`, and relativistic
412    /// effects dominate. Useful as a sanity guard before applying the boost
413    /// to financial time-series where very high velocities indicate data issues.
414    pub fn is_ultrarelativistic(&self) -> bool {
415        self.beta > 0.9
416    }
417
418    /// Relativistic spatial momentum factor: `gamma × beta`.
419    ///
420    /// This is the spatial component of the four-momentum (up to a mass factor).
421    /// At `beta = 0` the result is `0`; as `beta → 1` it diverges.
422    ///
423    /// # Complexity: O(1)
424    pub fn momentum_factor(&self) -> f64 {
425        self.gamma * self.beta
426    }
427
428    /// Relativistic momentum in natural units (c = 1): `p = γ·m·β`.
429    ///
430    /// For a position-size analogy: `mass` represents a notional stake and `beta`
431    /// represents velocity. The returned momentum scales that stake by the Lorentz
432    /// factor, giving a measure of "relativistic commitment" that grows non-linearly
433    /// as `beta → 1`.
434    ///
435    /// # Complexity: O(1)
436    pub fn momentum(&self, mass: f64) -> f64 {
437        self.gamma * mass * self.beta
438    }
439
440    /// Relativistic total energy in natural units (c = 1): `E = γ·m`.
441    ///
442    /// For `rest_mass` expressed in the same energy units as the result, this
443    /// returns the total energy of a particle with that rest mass moving at
444    /// the configured `beta`. At rest (`beta = 0`, `gamma = 1`) the result
445    /// equals `rest_mass`.
446    pub fn relativistic_energy(&self, rest_mass: f64) -> f64 {
447        self.gamma * rest_mass
448    }
449
450    /// Relativistic kinetic energy in natural units (c = 1): `(γ − 1) · m`.
451    ///
452    /// This is the total relativistic energy minus the rest energy, i.e. the
453    /// work done to accelerate the particle from rest to `beta`. At rest
454    /// (`beta = 0`, `gamma = 1`) the result is zero.
455    pub fn kinetic_energy(&self, rest_mass: f64) -> f64 {
456        (self.gamma - 1.0) * rest_mass
457    }
458
459    /// Lorentz invariant `E² - p²` for a particle with the given `rest_mass`.
460    ///
461    /// In natural units (`c = 1`) this equals `rest_mass²` for any velocity,
462    /// confirming that mass is a Lorentz scalar.
463    pub fn energy_momentum_invariant(&self, rest_mass: f64) -> f64 {
464        let e = self.gamma * rest_mass;
465        let p = self.relativistic_momentum(rest_mass);
466        e * e - p * p
467    }
468
469    /// Time component of the four-velocity: `u⁰ = γ`.
470    ///
471    /// In special relativity the four-velocity is `(γ, γβ, 0, 0)` (using the
472    /// convention where `c = 1`). This returns the time component, which equals
473    /// the Lorentz factor.  At rest `gamma = 1`; as `beta → 1` it diverges.
474    pub fn four_velocity_time(&self) -> f64 {
475        self.gamma
476    }
477
478    /// Proper time dilation: the ratio of proper time elapsed in the moving frame
479    /// to coordinate time `dt` elapsed in the rest frame.
480    ///
481    /// `proper_dt = dt / gamma` — a moving clock runs slower.
482    pub fn proper_time_dilation(&self, dt: f64) -> f64 {
483        dt / self.gamma
484    }
485
486    /// Computes the Lorentz-invariant spacetime interval between two events.
487    ///
488    /// The interval is defined as `ds² = (Δt)² - (Δx)²` (signature `+−`).
489    /// A positive result means the separation is time-like; negative means space-like;
490    /// zero means light-like (the events could be connected by a photon).
491    ///
492    /// This quantity is invariant under Lorentz boosts — `apply(p1)` and `apply(p2)`
493    /// will yield the same interval as `p1` and `p2` directly.
494    ///
495    /// # Complexity: O(1)
496    pub fn spacetime_interval(p1: SpacetimePoint, p2: SpacetimePoint) -> f64 {
497        let dt = p2.t - p1.t;
498        let dx = p2.x - p1.x;
499        dt * dt - dx * dx
500    }
501
502    /// Relativistic velocity addition as a static helper.
503    ///
504    /// Computes `(beta1 + beta2) / (1 + beta1 * beta2)` without constructing
505    /// a full `LorentzTransform`. Useful when only the composed velocity is
506    /// needed rather than the full transform object.
507    ///
508    /// # Errors
509    ///
510    /// Returns [`StreamError::LorentzConfigError`] if either input is outside
511    /// `[0, 1)` or if the composed velocity is `>= 1`.
512    pub fn velocity_addition(beta1: f64, beta2: f64) -> Result<f64, StreamError> {
513        if beta1.is_nan() || beta1 < 0.0 || beta1 >= 1.0 {
514            return Err(StreamError::LorentzConfigError {
515                reason: format!("beta1 must be in [0.0, 1.0), got {beta1}"),
516            });
517        }
518        if beta2.is_nan() || beta2 < 0.0 || beta2 >= 1.0 {
519            return Err(StreamError::LorentzConfigError {
520                reason: format!("beta2 must be in [0.0, 1.0), got {beta2}"),
521            });
522        }
523        let composed = (beta1 + beta2) / (1.0 + beta1 * beta2);
524        if composed >= 1.0 {
525            return Err(StreamError::LorentzConfigError {
526                reason: format!("composed velocity {composed} >= 1.0 (speed of light)"),
527            });
528        }
529        Ok(composed)
530    }
531
532    /// Proper time elapsed in the moving frame for a given coordinate time.
533    ///
534    /// Returns `coordinate_time / gamma`. For `beta = 0` (identity) this
535    /// equals `coordinate_time`; as `beta → 1` the proper time shrinks toward
536    /// zero (time dilation).
537    ///
538    /// # Complexity: O(1)
539    pub fn proper_time(&self, coordinate_time: f64) -> f64 {
540        coordinate_time / self.gamma
541    }
542
543    /// Return a new `LorentzTransform` that boosts in the opposite direction.
544    ///
545    /// If `self` has velocity `beta`, `self.inverse()` has velocity `-beta`.
546    /// Applying `self` then `self.inverse()` is the identity transform.
547    ///
548    /// This is equivalent to `LorentzTransform::new(-self.beta)` but avoids the
549    /// error branch since negating a valid beta always produces a valid result.
550    ///
551    /// # Complexity: O(1)
552    pub fn inverse(&self) -> Self {
553        // -beta is in (-1, 0] which is valid (we allow beta = 0 but not < 0 publicly;
554        // here we construct directly to allow the negated value).
555        let beta = -self.beta;
556        let gamma = 1.0 / (1.0 - beta * beta).sqrt();
557        Self { beta, gamma }
558    }
559
560    /// Coordinate (lab-frame) time for a given proper time.
561    ///
562    /// Returns `proper_time * gamma`. This is the inverse of
563    /// [`proper_time`](Self::proper_time): given elapsed proper time τ measured
564    /// by a clock in the moving frame, returns the corresponding coordinate time
565    /// `t = γτ` observed in the lab frame.
566    ///
567    /// # Complexity: O(1)
568    pub fn time_dilation(&self, proper_time: f64) -> f64 {
569        proper_time * self.gamma
570    }
571
572    /// Compose two Lorentz boosts into a single equivalent boost.
573    ///
574    /// Uses the relativistic velocity addition formula:
575    ///
576    /// ```text
577    ///     beta_composed = (beta_1 + beta_2) / (1 + beta_1 * beta_2)
578    /// ```
579    ///
580    /// This is the physical law stating that applying boost `self` then boost
581    /// `other` is equivalent to a single boost with the composed velocity.
582    ///
583    /// # Errors
584    ///
585    /// Returns [`StreamError::LorentzConfigError`] if the composed velocity
586    /// reaches or exceeds 1 (the speed of light), which cannot happen for
587    /// valid inputs but is checked defensively.
588    pub fn compose(&self, other: &LorentzTransform) -> Result<Self, StreamError> {
589        let b1 = self.beta;
590        let b2 = other.beta;
591        let composed = (b1 + b2) / (1.0 + b1 * b2);
592        LorentzTransform::new(composed)
593    }
594
595    /// Compose a sequence of boosts into a single equivalent transform.
596    ///
597    /// Applies [`compose`](Self::compose) left-to-right over `betas`, starting
598    /// from the identity boost (beta = 0). An empty slice returns the identity.
599    ///
600    /// # Errors
601    ///
602    /// Returns [`StreamError::LorentzConfigError`] if any beta is invalid or
603    /// the accumulated velocity reaches the speed of light.
604    pub fn boost_chain(betas: &[f64]) -> Result<Self, StreamError> {
605        let mut result = LorentzTransform::new(0.0)?;
606        for &beta in betas {
607            let next = LorentzTransform::new(beta)?;
608            result = result.compose(&next)?;
609        }
610        Ok(result)
611    }
612
613    /// Returns `true` if this transform is effectively the identity (`beta ≈ 0`).
614    ///
615    /// The identity leaves spacetime coordinates unchanged: `t' = t`, `x' = x`.
616    /// Uses a tolerance of `1e-10`.
617    pub fn is_identity(&self) -> bool {
618        self.beta.abs() < 1e-10
619    }
620
621    /// Relativistic composition of two boosts (Einstein velocity addition).
622    ///
623    /// The composed transform moves at `β = (β₁ + β₂) / (1 + β₁β₂)`, which is
624    /// equivalent to adding rapidities: `tanh⁻¹(β_composed) = tanh⁻¹(β₁) + tanh⁻¹(β₂)`.
625    ///
626    /// # Errors
627    ///
628    /// Returns [`StreamError::LorentzConfigError`] if the composed beta reaches or
629    /// exceeds the speed of light (should not happen for valid sub-luminal inputs).
630    pub fn composition(&self, other: &Self) -> Result<Self, StreamError> {
631        let b1 = self.beta;
632        let b2 = other.beta;
633        let denom = 1.0 + b1 * b2;
634        if denom.abs() < 1e-15 {
635            return Err(StreamError::LorentzConfigError {
636                reason: "boost composition denominator too small (near-singular)".into(),
637            });
638        }
639        Self::new((b1 + b2) / denom)
640    }
641
642    /// Relativistic Doppler factor for a source moving toward the observer at `β`.
643    ///
644    /// `D = √((1 + β) / (1 − β))`.
645    /// `D > 1` (blue-shift) when approaching; `D < 1` (red-shift) when receding;
646    /// `D = 1` at rest.
647    pub fn doppler_factor(&self) -> f64 {
648        ((1.0 + self.beta) / (1.0 - self.beta)).sqrt()
649    }
650
651    /// Relativistic aberration angle for a light ray with direction cosine `cos_theta`.
652    ///
653    /// Computes the observed angle using `cos θ' = (cos θ + β) / (1 + β cos θ)`,
654    /// returning the aberrated angle in radians in `[0, π]`.
655    pub fn aberration_angle(&self, cos_theta: f64) -> f64 {
656        let cos_prime = (cos_theta + self.beta) / (1.0 + self.beta * cos_theta);
657        cos_prime.clamp(-1.0, 1.0).acos()
658    }
659
660    /// Relativistic mass for a particle with `rest_mass` moving at `β`.
661    ///
662    /// `m = rest_mass × γ` where `γ` is the Lorentz factor.
663    pub fn relativistic_mass(&self, rest_mass: f64) -> f64 {
664        rest_mass * self.gamma()
665    }
666
667    /// Ratio of kinetic energy to rest energy: `γ - 1`.
668    ///
669    /// Zero at rest (β = 0); grows without bound as β → 1.
670    pub fn energy_ratio(&self) -> f64 {
671        self.gamma() - 1.0
672    }
673
674    /// Warp factor in the Star Trek convention: `w = γ^(1/3)`.
675    ///
676    /// A pure curiosity / educational mapping; warp 1 corresponds to β = 0 (rest), and
677    /// higher warp values correspond to higher Lorentz factors.
678    pub fn warp_factor(&self) -> f64 {
679        self.gamma().cbrt()
680    }
681
682    /// Four-momentum `(E, p)` in natural units (`c = 1`).
683    ///
684    /// Returns `(γ·mass, γ·mass·β)` where the first component is the relativistic
685    /// energy and the second is the relativistic momentum magnitude.
686    pub fn four_momentum(&self, mass: f64) -> (f64, f64) {
687        let g = self.gamma();
688        (g * mass, g * mass * self.beta)
689    }
690
691    /// Relativistic momentum per unit rest mass: `γ × β`.
692    ///
693    /// Scales linearly with β at low speeds; diverges as β → 1.
694    pub fn momentum_ratio(&self) -> f64 {
695        self.gamma() * self.beta
696    }
697
698    /// Returns `true` if β > 0.9, the conventional ultra-relativistic threshold.
699    pub fn is_ultra_relativistic(&self) -> bool {
700        self.beta > 0.9
701    }
702
703    /// First-order Taylor approximation of γ valid for small β: `1 + β²/2`.
704    ///
705    /// Accurate to ~0.1% for β < 0.1; diverges significantly above β ≈ 0.4.
706    pub fn lorentz_factor_approx(&self) -> f64 {
707        1.0 + 0.5 * self.beta * self.beta
708    }
709
710    /// Ratio of this transform's β to another's: `self.beta / other.beta`.
711    ///
712    /// Returns `f64::INFINITY` if `other.beta` is zero and `self.beta > 0`,
713    /// and `f64::NAN` if both are zero.
714    pub fn velocity_ratio(&self, other: &Self) -> f64 {
715        self.beta / other.beta
716    }
717
718    /// Proper length from an observed (length-contracted) length: `observed * gamma`.
719    ///
720    /// Inverse of length contraction: recovers the rest-frame length from an
721    /// observed measurement made in the moving frame.
722    pub fn proper_length(&self, observed: f64) -> f64 {
723        observed * self.gamma()
724    }
725
726    /// Observed (length-contracted) length given a `rest_length`: `rest_length / gamma`.
727    ///
728    /// A moving object of rest length `L₀` appears shortened to `L₀ / γ` in the observer frame.
729    pub fn length_contraction(&self, rest_length: f64) -> f64 {
730        rest_length / self.gamma()
731    }
732
733    /// Classifies a spacetime interval `(dt, dx)` as `"timelike"`, `"lightlike"`, or `"spacelike"`.
734    ///
735    /// Spacetime interval: `s² = dt² - dx²` (in natural units, c = 1).
736    /// - `s² > 0` → timelike (causally connected)
737    /// - `s² = 0` → lightlike (on the light cone)
738    /// - `s² < 0` → spacelike (causally disconnected)
739    pub fn light_cone_check(dt: f64, dx: f64) -> &'static str {
740        let s_sq = dt * dt - dx * dx;
741        if s_sq > 1e-12 {
742            "timelike"
743        } else if s_sq < -1e-12 {
744            "spacelike"
745        } else {
746            "lightlike"
747        }
748    }
749
750    /// Lorentz-invariant rest mass from energy and momentum: `m = sqrt(E² - p²)`.
751    ///
752    /// In natural units (`c = 1`). Returns `None` if `E² < p²` (unphysical configuration).
753    pub fn lorentz_invariant_mass(energy: f64, momentum: f64) -> Option<f64> {
754        let m_sq = energy * energy - momentum * momentum;
755        if m_sq < 0.0 { return None; }
756        Some(m_sq.sqrt())
757    }
758
759    /// Relativistic aberration: corrected cosine of angle when viewed from boosted frame.
760    ///
761    /// `cos_theta' = (cos_theta - beta) / (1 - beta * cos_theta)`.
762    /// Returns `None` if the denominator is zero (numerically degenerate case).
763    pub fn aberration_correction(&self, cos_theta: f64) -> Option<f64> {
764        let denom = 1.0 - self.beta * cos_theta;
765        if denom.abs() < 1e-15 { return None; }
766        Some((cos_theta - self.beta) / denom)
767    }
768
769    /// Relativistic Doppler ratio: `sqrt((1 + beta) / (1 - beta))`.
770    ///
771    /// Values > 1 represent blue-shift (approaching); < 1 red-shift (receding).
772    /// At rest (beta = 0) returns 1.0.
773    pub fn doppler_ratio(&self) -> f64 {
774        ((1.0 + self.beta) / (1.0 - self.beta)).sqrt()
775    }
776
777    /// Dilated coordinate time in milliseconds for a given proper time `proper_ms`.
778    ///
779    /// `t_coordinate = gamma * t_proper`. A moving clock ticks slower by factor γ.
780    pub fn time_dilation_ms(&self, proper_ms: f64) -> f64 {
781        self.gamma * proper_ms
782    }
783
784    /// Length contraction: contracted length for a given proper (rest-frame) length.
785    ///
786    /// `L_contracted = L_proper / gamma`. Objects appear shorter in the direction of motion.
787    pub fn space_contraction(&self, proper_length: f64) -> f64 {
788        proper_length / self.gamma
789    }
790
791    /// Relativistic proper acceleration for a given coordinate force and rest mass.
792    ///
793    /// `a = F / (gamma^3 * m)` — coordinate acceleration decreases as gamma grows.
794    /// Returns `None` if `mass` is zero.
795    pub fn proper_acceleration(&self, force: f64, mass: f64) -> Option<f64> {
796        if mass == 0.0 { return None; }
797        Some(force / (self.gamma.powi(3) * mass))
798    }
799
800    /// Relativistic momentum proxy: `gamma * beta` (natural units, rest mass = 1).
801    ///
802    /// Spatial component of the four-velocity; additive in rapidity space.
803    pub fn momentum_rapidity(&self) -> f64 {
804        self.gamma * self.beta
805    }
806
807    /// Inverse Lorentz factor: `1 / gamma`.
808    ///
809    /// Equals `sqrt(1 - beta^2)`. Approaches 1 at low speeds; 0 at light speed.
810    pub fn inverse_gamma(&self) -> f64 {
811        1.0 / self.gamma
812    }
813
814    /// Compose two collinear boosts: `beta_total = (beta1 + beta2) / (1 + beta1*beta2)`.
815    ///
816    /// Returns the combined beta from relativistic velocity addition.
817    /// Returns `Err` if the result would equal or exceed c (|beta| >= 1).
818    pub fn boost_composition(beta1: f64, beta2: f64) -> Result<f64, crate::error::StreamError> {
819        let denom = 1.0 + beta1 * beta2;
820        if denom.abs() < 1e-15 {
821            return Err(crate::error::StreamError::LorentzConfigError {
822                reason: "degenerate boost composition".into(),
823            });
824        }
825        let result = (beta1 + beta2) / denom;
826        if result.abs() >= 1.0 {
827            return Err(crate::error::StreamError::LorentzConfigError {
828                reason: "boost exceeds c".into(),
829            });
830        }
831        Ok(result)
832    }
833}
834
835#[cfg(test)]
836mod tests {
837    use super::*;
838
839    const EPS: f64 = 1e-10;
840
841    fn approx_eq(a: f64, b: f64) -> bool {
842        (a - b).abs() < EPS
843    }
844
845    fn point_approx_eq(a: SpacetimePoint, b: SpacetimePoint) -> bool {
846        approx_eq(a.t, b.t) && approx_eq(a.x, b.x)
847    }
848
849    // ── Construction ─────────────────────────────────────────────────────────
850
851    #[test]
852    fn test_new_valid_beta() {
853        let lt = LorentzTransform::new(0.5).unwrap();
854        assert!((lt.beta() - 0.5).abs() < EPS);
855    }
856
857    #[test]
858    fn test_new_beta_zero() {
859        let lt = LorentzTransform::new(0.0).unwrap();
860        assert_eq!(lt.beta(), 0.0);
861        assert!((lt.gamma() - 1.0).abs() < EPS);
862    }
863
864    #[test]
865    fn test_new_beta_one_returns_error() {
866        let err = LorentzTransform::new(1.0).unwrap_err();
867        assert!(matches!(err, StreamError::LorentzConfigError { .. }));
868    }
869
870    #[test]
871    fn test_new_beta_above_one_returns_error() {
872        let err = LorentzTransform::new(1.5).unwrap_err();
873        assert!(matches!(err, StreamError::LorentzConfigError { .. }));
874    }
875
876    #[test]
877    fn test_new_beta_negative_returns_error() {
878        let err = LorentzTransform::new(-0.1).unwrap_err();
879        assert!(matches!(err, StreamError::LorentzConfigError { .. }));
880    }
881
882    #[test]
883    fn test_new_beta_nan_returns_error() {
884        let err = LorentzTransform::new(f64::NAN).unwrap_err();
885        assert!(matches!(err, StreamError::LorentzConfigError { .. }));
886    }
887
888    // ── beta = 0 is identity ─────────────────────────────────────────────────
889
890    #[test]
891    fn test_beta_zero_is_identity_transform() {
892        let lt = LorentzTransform::new(0.0).unwrap();
893        let p = SpacetimePoint::new(3.0, 4.0);
894        let q = lt.transform(p);
895        assert!(point_approx_eq(p, q), "beta=0 must be identity, got {q:?}");
896    }
897
898    // ── Time dilation ─────────────────────────────────────────────────────────
899
900    /// For a point at x=0, t' = gamma * t (pure time dilation).
901    #[test]
902    fn test_time_dilation_at_x_zero() {
903        let lt = LorentzTransform::new(0.6).unwrap();
904        let p = SpacetimePoint::new(5.0, 0.0);
905        let q = lt.transform(p);
906        let expected_t = lt.gamma() * 5.0;
907        assert!(approx_eq(q.t, expected_t));
908    }
909
910    #[test]
911    fn test_dilate_time_helper() {
912        let lt = LorentzTransform::new(0.6).unwrap();
913        assert!(approx_eq(lt.dilate_time(1.0), lt.gamma()));
914    }
915
916    // ── Length contraction ────────────────────────────────────────────────────
917
918    /// For a point at t=0, x' = gamma * x (pure length contraction).
919    #[test]
920    fn test_length_contraction_at_t_zero() {
921        let lt = LorentzTransform::new(0.6).unwrap();
922        let p = SpacetimePoint::new(0.0, 5.0);
923        let q = lt.transform(p);
924        let expected_x = lt.gamma() * 5.0;
925        assert!(approx_eq(q.x, expected_x));
926    }
927
928    #[test]
929    fn test_contract_length_helper() {
930        let lt = LorentzTransform::new(0.6).unwrap();
931        assert!(approx_eq(lt.contract_length(1.0), lt.gamma()));
932    }
933
934    // ── Known beta values ─────────────────────────────────────────────────────
935
936    /// For beta = 0.6, gamma = 1.25 (classic textbook value).
937    #[test]
938    fn test_known_beta_0_6_gamma_is_1_25() {
939        let lt = LorentzTransform::new(0.6).unwrap();
940        assert!(
941            (lt.gamma() - 1.25).abs() < 1e-9,
942            "gamma should be 1.25, got {}",
943            lt.gamma()
944        );
945    }
946
947    /// For beta = 0.8, gamma = 5/3 approximately 1.6667.
948    #[test]
949    fn test_known_beta_0_8_gamma() {
950        let lt = LorentzTransform::new(0.8).unwrap();
951        let expected_gamma = 5.0 / 3.0;
952        assert!((lt.gamma() - expected_gamma).abs() < 1e-9);
953    }
954
955    /// For beta = 0.5, gamma = 2/sqrt(3) approximately 1.1547.
956    #[test]
957    fn test_known_beta_0_5_gamma() {
958        let lt = LorentzTransform::new(0.5).unwrap();
959        let expected_gamma = 2.0 / 3.0f64.sqrt();
960        assert!((lt.gamma() - expected_gamma).abs() < 1e-9);
961    }
962
963    /// Transform a known point with beta=0.6 and verify the result manually.
964    ///
965    /// Point (t=1, x=0): t' = 1.25 * (1 - 0.6*0) = 1.25; x' = 1.25*(0 - 0.6) = -0.75
966    #[test]
967    fn test_transform_known_point_beta_0_6() {
968        let lt = LorentzTransform::new(0.6).unwrap();
969        let p = SpacetimePoint::new(1.0, 0.0);
970        let q = lt.transform(p);
971        assert!((q.t - 1.25).abs() < 1e-9);
972        assert!((q.x - (-0.75)).abs() < 1e-9);
973    }
974
975    // ── Inverse round-trip ────────────────────────────────────────────────────
976
977    #[test]
978    fn test_inverse_transform_roundtrip() {
979        let lt = LorentzTransform::new(0.7).unwrap();
980        let p = SpacetimePoint::new(3.0, 1.5);
981        let q = lt.transform(p);
982        let r = lt.inverse_transform(q);
983        assert!(
984            point_approx_eq(r, p),
985            "round-trip failed: expected {p:?}, got {r:?}"
986        );
987    }
988
989    // ── Batch transform ───────────────────────────────────────────────────────
990
991    #[test]
992    fn test_transform_batch_length_preserved() {
993        let lt = LorentzTransform::new(0.3).unwrap();
994        let pts = vec![
995            SpacetimePoint::new(0.0, 1.0),
996            SpacetimePoint::new(1.0, 2.0),
997            SpacetimePoint::new(2.0, 3.0),
998        ];
999        let out = lt.transform_batch(&pts);
1000        assert_eq!(out.len(), pts.len());
1001    }
1002
1003    #[test]
1004    fn test_transform_batch_matches_individual() {
1005        let lt = LorentzTransform::new(0.4).unwrap();
1006        let pts = vec![SpacetimePoint::new(1.0, 0.5), SpacetimePoint::new(2.0, 1.5)];
1007        let batch = lt.transform_batch(&pts);
1008        for (i, &p) in pts.iter().enumerate() {
1009            let individual = lt.transform(p);
1010            assert!(
1011                point_approx_eq(batch[i], individual),
1012                "batch[{i}] differs from individual transform"
1013            );
1014        }
1015    }
1016
1017    #[test]
1018    fn test_inverse_transform_batch_roundtrip() {
1019        let lt = LorentzTransform::new(0.5).unwrap();
1020        let pts = vec![
1021            SpacetimePoint::new(1.0, 0.0),
1022            SpacetimePoint::new(2.0, 1.0),
1023            SpacetimePoint::new(0.0, 3.0),
1024        ];
1025        let transformed = lt.transform_batch(&pts);
1026        let restored = lt.inverse_transform_batch(&transformed);
1027        for (i, (&orig, &rest)) in pts.iter().zip(restored.iter()).enumerate() {
1028            assert!(
1029                point_approx_eq(orig, rest),
1030                "round-trip failed at index {i}: expected {orig:?}, got {rest:?}"
1031            );
1032        }
1033    }
1034
1035    #[test]
1036    fn test_inverse_transform_batch_length_preserved() {
1037        let lt = LorentzTransform::new(0.3).unwrap();
1038        let pts = vec![SpacetimePoint::new(0.0, 0.0), SpacetimePoint::new(1.0, 1.0)];
1039        assert_eq!(lt.inverse_transform_batch(&pts).len(), pts.len());
1040    }
1041
1042    // ── SpacetimePoint ────────────────────────────────────────────────────────
1043
1044    #[test]
1045    fn test_spacetime_point_fields() {
1046        let p = SpacetimePoint::new(1.5, 2.5);
1047        assert_eq!(p.t, 1.5);
1048        assert_eq!(p.x, 2.5);
1049    }
1050
1051    #[test]
1052    fn test_spacetime_point_equality() {
1053        let p = SpacetimePoint::new(1.0, 2.0);
1054        let q = SpacetimePoint::new(1.0, 2.0);
1055        assert_eq!(p, q);
1056    }
1057
1058    // ── Compose ───────────────────────────────────────────────────────────────
1059
1060    /// Two zero boosts compose to zero.
1061    #[test]
1062    fn test_compose_identity_with_identity() {
1063        let lt = LorentzTransform::new(0.0).unwrap();
1064        let composed = lt.compose(&lt).unwrap();
1065        assert!(approx_eq(composed.beta(), 0.0));
1066    }
1067
1068    /// Relativistic addition: 0.5 + 0.5 = 0.8 (not 1.0).
1069    #[test]
1070    fn test_compose_0_5_and_0_5() {
1071        let lt = LorentzTransform::new(0.5).unwrap();
1072        let composed = lt.compose(&lt).unwrap();
1073        let expected = (0.5 + 0.5) / (1.0 + 0.5 * 0.5); // = 1.0 / 1.25 = 0.8
1074        assert!((composed.beta() - expected).abs() < EPS);
1075    }
1076
1077    /// Composing boost with its inverse should give the identity (beta ≈ 0).
1078    #[test]
1079    fn test_compose_with_negative_is_identity() {
1080        // Applying +0.3, then -0.3 should cancel out: (0.3 - 0.3) / (1 - 0.09) = 0
1081        let lt_fwd = LorentzTransform::new(0.3).unwrap();
1082        let lt_bwd = LorentzTransform::new(0.3).unwrap();
1083        // Compose forward boost with itself is not identity; test associativity
1084        // For actual cancellation we'd need negative beta — out of domain.
1085        // Instead verify compose gives valid beta < 1.
1086        let composed = lt_fwd.compose(&lt_bwd).unwrap();
1087        assert!(composed.beta() < 1.0);
1088    }
1089
1090    // ── Rapidity ──────────────────────────────────────────────────────────────
1091
1092    /// beta=0 has rapidity 0 (atanh(0) = 0).
1093    #[test]
1094    fn test_rapidity_zero_beta() {
1095        let lt = LorentzTransform::new(0.0).unwrap();
1096        assert!(approx_eq(lt.rapidity(), 0.0));
1097    }
1098
1099    /// Rapidity for beta=0.5: atanh(0.5) ≈ 0.5493.
1100    #[test]
1101    fn test_rapidity_known_value() {
1102        let lt = LorentzTransform::new(0.5).unwrap();
1103        let expected = (0.5f64).atanh();
1104        assert!((lt.rapidity() - expected).abs() < EPS);
1105    }
1106
1107    /// Rapidities are additive: rapidity(compose(a, b)) == rapidity(a) + rapidity(b).
1108    #[test]
1109    fn test_rapidity_is_additive_under_composition() {
1110        let lt1 = LorentzTransform::new(0.3).unwrap();
1111        let lt2 = LorentzTransform::new(0.4).unwrap();
1112        let composed = lt1.compose(&lt2).unwrap();
1113        let sum_rapidities = lt1.rapidity() + lt2.rapidity();
1114        assert!(
1115            (composed.rapidity() - sum_rapidities).abs() < 1e-9,
1116            "rapidity should be additive: {} vs {}",
1117            composed.rapidity(),
1118            sum_rapidities
1119        );
1120    }
1121
1122    // ── Velocity addition (static) ────────────────────────────────────────────
1123
1124    #[test]
1125    fn test_velocity_addition_known_values() {
1126        // 0.5 + 0.5 = 0.8 (same as compose test)
1127        let result = LorentzTransform::velocity_addition(0.5, 0.5).unwrap();
1128        assert!((result - 0.8).abs() < EPS);
1129    }
1130
1131    #[test]
1132    fn test_velocity_addition_identity_with_zero() {
1133        let result = LorentzTransform::velocity_addition(0.0, 0.6).unwrap();
1134        assert!((result - 0.6).abs() < EPS);
1135    }
1136
1137    #[test]
1138    fn test_velocity_addition_invalid_beta_rejected() {
1139        assert!(LorentzTransform::velocity_addition(1.0, 0.5).is_err());
1140        assert!(LorentzTransform::velocity_addition(0.5, 1.0).is_err());
1141        assert!(LorentzTransform::velocity_addition(-0.1, 0.5).is_err());
1142    }
1143
1144    #[test]
1145    fn test_velocity_addition_matches_compose() {
1146        let b1 = 0.3;
1147        let b2 = 0.4;
1148        let static_result = LorentzTransform::velocity_addition(b1, b2).unwrap();
1149        let lt1 = LorentzTransform::new(b1).unwrap();
1150        let lt2 = LorentzTransform::new(b2).unwrap();
1151        let composed = lt1.compose(&lt2).unwrap();
1152        assert!((static_result - composed.beta()).abs() < EPS);
1153    }
1154
1155    // ── Proper time ───────────────────────────────────────────────────────────
1156
1157    /// beta=0: proper_time == coordinate_time (no dilation).
1158    #[test]
1159    fn test_proper_time_identity_at_zero_beta() {
1160        let lt = LorentzTransform::new(0.0).unwrap();
1161        assert!(approx_eq(lt.proper_time(10.0), 10.0));
1162    }
1163
1164    /// proper_time = coordinate_time / gamma; always <= coordinate_time.
1165    #[test]
1166    fn test_proper_time_less_than_coordinate_time() {
1167        let lt = LorentzTransform::new(0.6).unwrap(); // gamma = 1.25
1168        let tau = lt.proper_time(5.0);
1169        // tau = 5.0 / 1.25 = 4.0
1170        assert!((tau - 4.0).abs() < EPS);
1171        assert!(tau < 5.0);
1172    }
1173
1174    /// proper_time(dilate_time(t)) should round-trip to t.
1175    #[test]
1176    fn test_proper_time_roundtrip_with_dilate_time() {
1177        let lt = LorentzTransform::new(0.8).unwrap();
1178        let t = 3.0;
1179        let dilated = lt.dilate_time(t);
1180        let recovered = lt.proper_time(dilated);
1181        assert!(approx_eq(recovered, t));
1182    }
1183
1184    /// Compose and then transform equals applying both transforms sequentially.
1185    #[test]
1186    fn test_compose_equals_sequential_transforms() {
1187        let lt1 = LorentzTransform::new(0.3).unwrap();
1188        let lt2 = LorentzTransform::new(0.4).unwrap();
1189        let composed = lt1.compose(&lt2).unwrap();
1190
1191        let p = SpacetimePoint::new(2.0, 1.0);
1192        let sequential = lt2.transform(lt1.transform(p));
1193        let single = composed.transform(p);
1194        assert!(
1195            point_approx_eq(sequential, single),
1196            "composed boost must equal sequential: {sequential:?} vs {single:?}"
1197        );
1198    }
1199
1200    // ── SpacetimePoint::displacement ─────────────────────────────────────────
1201
1202    #[test]
1203    fn test_displacement_gives_correct_deltas() {
1204        let a = SpacetimePoint::new(1.0, 2.0);
1205        let b = SpacetimePoint::new(4.0, 6.0);
1206        let d = a.displacement(b);
1207        assert!(approx_eq(d.t, 3.0));
1208        assert!(approx_eq(d.x, 4.0));
1209    }
1210
1211    #[test]
1212    fn test_displacement_to_self_is_zero() {
1213        let a = SpacetimePoint::new(3.0, 7.0);
1214        let d = a.displacement(a);
1215        assert!(approx_eq(d.t, 0.0));
1216        assert!(approx_eq(d.x, 0.0));
1217    }
1218
1219    // ── LorentzTransform::boost_chain ────────────────────────────────────────
1220
1221    #[test]
1222    fn test_boost_chain_empty_is_identity() {
1223        let chain = LorentzTransform::boost_chain(&[]).unwrap();
1224        assert!(approx_eq(chain.beta(), 0.0));
1225        assert!(approx_eq(chain.gamma(), 1.0));
1226    }
1227
1228    #[test]
1229    fn test_boost_chain_single_equals_new() {
1230        let chain = LorentzTransform::boost_chain(&[0.5]).unwrap();
1231        let direct = LorentzTransform::new(0.5).unwrap();
1232        assert!(approx_eq(chain.beta(), direct.beta()));
1233    }
1234
1235    #[test]
1236    fn test_boost_chain_two_equals_compose() {
1237        let chain = LorentzTransform::boost_chain(&[0.3, 0.4]).unwrap();
1238        let manual = LorentzTransform::new(0.3)
1239            .unwrap()
1240            .compose(&LorentzTransform::new(0.4).unwrap())
1241            .unwrap();
1242        assert!(approx_eq(chain.beta(), manual.beta()));
1243    }
1244
1245    #[test]
1246    fn test_boost_chain_invalid_beta_returns_error() {
1247        assert!(LorentzTransform::boost_chain(&[1.0]).is_err());
1248        assert!(LorentzTransform::boost_chain(&[0.3, -0.1]).is_err());
1249    }
1250
1251    // ── LorentzTransform::time_dilation ───────────────────────────────────────
1252
1253    #[test]
1254    fn test_time_dilation_identity_at_zero_beta() {
1255        // beta=0, gamma=1 → time_dilation(t) == t
1256        let lt = LorentzTransform::new(0.0).unwrap();
1257        assert!(approx_eq(lt.time_dilation(10.0), 10.0));
1258    }
1259
1260    #[test]
1261    fn test_time_dilation_inverse_of_proper_time() {
1262        // time_dilation(proper_time(t)) should round-trip to t
1263        let lt = LorentzTransform::new(0.6).unwrap();
1264        let t = 5.0_f64;
1265        let tau = lt.proper_time(t);
1266        assert!(approx_eq(lt.time_dilation(tau), t));
1267    }
1268
1269    #[test]
1270    fn test_time_dilation_greater_than_proper_time() {
1271        // For beta > 0: coordinate_time = gamma * tau > tau (moving clocks run slower)
1272        let lt = LorentzTransform::new(0.8).unwrap();
1273        let proper = 3.0_f64;
1274        let coord = lt.time_dilation(proper);
1275        assert!(coord > proper);
1276    }
1277
1278    // ── LorentzTransform::inverse ─────────────────────────────────────────────
1279
1280    #[test]
1281    fn test_inverse_negates_beta() {
1282        let lt = LorentzTransform::new(0.6).unwrap();
1283        let inv = lt.inverse();
1284        assert!(approx_eq(inv.beta(), -0.6));
1285    }
1286
1287    #[test]
1288    fn test_inverse_preserves_gamma_magnitude() {
1289        let lt = LorentzTransform::new(0.6).unwrap();
1290        let inv = lt.inverse();
1291        // gamma only depends on beta^2, so |gamma| is the same
1292        assert!(approx_eq(inv.gamma(), lt.gamma()));
1293    }
1294
1295    #[test]
1296    fn test_inverse_roundtrips_point() {
1297        let lt = LorentzTransform::new(0.5).unwrap();
1298        let p = SpacetimePoint::new(3.0, 1.0);
1299        let boosted = lt.transform(p);
1300        let recovered = lt.inverse().transform(boosted);
1301        assert!(point_approx_eq(recovered, p));
1302    }
1303
1304    // ── SpacetimePoint::norm_sq / is_timelike / is_lightlike / is_spacelike ──
1305
1306    #[test]
1307    fn test_norm_sq_timelike() {
1308        // t=3, x=1 → 9 - 1 = 8 > 0
1309        let p = SpacetimePoint::new(3.0, 1.0);
1310        assert!((p.norm_sq() - 8.0).abs() < EPS);
1311        assert!(p.is_timelike());
1312        assert!(!p.is_spacelike());
1313        assert!(!p.is_lightlike());
1314    }
1315
1316    #[test]
1317    fn test_norm_sq_spacelike() {
1318        // t=1, x=3 → 1 - 9 = -8 < 0
1319        let p = SpacetimePoint::new(1.0, 3.0);
1320        assert!((p.norm_sq() - (-8.0)).abs() < EPS);
1321        assert!(p.is_spacelike());
1322        assert!(!p.is_timelike());
1323        assert!(!p.is_lightlike());
1324    }
1325
1326    #[test]
1327    fn test_norm_sq_lightlike() {
1328        // t=1, x=1 → 1 - 1 = 0
1329        let p = SpacetimePoint::new(1.0, 1.0);
1330        assert!(p.norm_sq().abs() < EPS);
1331        assert!(p.is_lightlike());
1332        assert!(!p.is_timelike());
1333        assert!(!p.is_spacelike());
1334    }
1335
1336    #[test]
1337    fn test_norm_sq_origin_is_lightlike() {
1338        let p = SpacetimePoint::new(0.0, 0.0);
1339        assert!(p.is_lightlike());
1340    }
1341
1342    // ── LorentzTransform::is_identity ─────────────────────────────────────────
1343
1344    #[test]
1345    fn test_is_identity_beta_zero() {
1346        let lt = LorentzTransform::new(0.0).unwrap();
1347        assert!(lt.is_identity());
1348    }
1349
1350    #[test]
1351    fn test_is_not_identity_nonzero_beta() {
1352        let lt = LorentzTransform::new(0.5).unwrap();
1353        assert!(!lt.is_identity());
1354    }
1355
1356    #[test]
1357    fn test_is_identity_empty_boost_chain() {
1358        let lt = LorentzTransform::boost_chain(&[]).unwrap();
1359        assert!(lt.is_identity());
1360    }
1361
1362    // ── LorentzTransform::beta_from_gamma ─────────────────────────────────────
1363
1364    #[test]
1365    fn test_beta_from_gamma_identity() {
1366        // gamma=1.0 → beta=0.0
1367        let beta = LorentzTransform::beta_from_gamma(1.0).unwrap();
1368        assert!(approx_eq(beta, 0.0));
1369    }
1370
1371    #[test]
1372    fn test_beta_from_gamma_roundtrip() {
1373        let lt = LorentzTransform::new(0.6).unwrap();
1374        let recovered = LorentzTransform::beta_from_gamma(lt.gamma()).unwrap();
1375        assert!(approx_eq(recovered, 0.6));
1376    }
1377
1378    #[test]
1379    fn test_beta_from_gamma_invalid_rejected() {
1380        assert!(LorentzTransform::beta_from_gamma(0.5).is_err()); // < 1
1381        assert!(LorentzTransform::beta_from_gamma(f64::NAN).is_err());
1382        assert!(LorentzTransform::beta_from_gamma(-1.0).is_err());
1383    }
1384
1385    // ── LorentzTransform::from_rapidity ───────────────────────────────────────
1386
1387    #[test]
1388    fn test_from_rapidity_zero_is_identity() {
1389        let lt = LorentzTransform::from_rapidity(0.0).unwrap();
1390        assert!(lt.is_identity());
1391    }
1392
1393    #[test]
1394    fn test_from_rapidity_positive_gives_valid_beta() {
1395        // eta = 0.5 → beta = tanh(0.5) ≈ 0.4621
1396        let lt = LorentzTransform::from_rapidity(0.5).unwrap();
1397        assert!((lt.beta() - 0.5_f64.tanh()).abs() < EPS);
1398    }
1399
1400    #[test]
1401    fn test_from_rapidity_infinite_rejected() {
1402        assert!(LorentzTransform::from_rapidity(f64::INFINITY).is_err());
1403        assert!(LorentzTransform::from_rapidity(f64::NEG_INFINITY).is_err());
1404        assert!(LorentzTransform::from_rapidity(f64::NAN).is_err());
1405    }
1406
1407    // ── SpacetimePoint::distance_to ───────────────────────────────────────────
1408
1409    #[test]
1410    fn test_distance_to_timelike_separation() {
1411        // t=3, x=0 vs t=0, x=0 → Δt=3, Δx=0 → norm_sq=9 → dist=3
1412        let a = SpacetimePoint::new(0.0, 0.0);
1413        let b = SpacetimePoint::new(3.0, 0.0);
1414        assert!((a.distance_to(b) - 3.0).abs() < EPS);
1415    }
1416
1417    #[test]
1418    fn test_distance_to_spacelike_separation() {
1419        // t=0 vs x=4 → norm_sq=0-16=-16 → dist=4
1420        let a = SpacetimePoint::new(0.0, 0.0);
1421        let b = SpacetimePoint::new(0.0, 4.0);
1422        assert!((a.distance_to(b) - 4.0).abs() < EPS);
1423    }
1424
1425    #[test]
1426    fn test_distance_to_same_point_is_zero() {
1427        let p = SpacetimePoint::new(2.0, 3.0);
1428        assert!(p.distance_to(p).abs() < EPS);
1429    }
1430
1431    // ── LorentzTransform::gamma_at ────────────────────────────────────────────
1432
1433    #[test]
1434    fn test_gamma_at_zero_is_one() {
1435        assert!((LorentzTransform::gamma_at(0.0) - 1.0).abs() < EPS);
1436    }
1437
1438    #[test]
1439    fn test_gamma_at_matches_constructor_gamma() {
1440        let lt = LorentzTransform::new(0.6).unwrap();
1441        let expected = lt.gamma();
1442        let computed = LorentzTransform::gamma_at(0.6);
1443        assert!((computed - expected).abs() < EPS);
1444    }
1445
1446    #[test]
1447    fn test_gamma_at_one_is_infinite() {
1448        assert!(LorentzTransform::gamma_at(1.0).is_infinite());
1449    }
1450
1451    #[test]
1452    fn test_gamma_at_above_one_is_nan() {
1453        assert!(LorentzTransform::gamma_at(1.1).is_nan());
1454    }
1455
1456    #[test]
1457    fn test_from_velocity_matches_beta_ratio() {
1458        // v = 0.6c → beta = 0.6
1459        let t = LorentzTransform::from_velocity(0.6, 1.0).unwrap();
1460        assert!((t.beta() - 0.6).abs() < 1e-12);
1461    }
1462
1463    #[test]
1464    fn test_from_velocity_zero_c_returns_error() {
1465        assert!(matches!(
1466            LorentzTransform::from_velocity(0.5, 0.0),
1467            Err(StreamError::LorentzConfigError { .. })
1468        ));
1469    }
1470
1471    #[test]
1472    fn test_from_velocity_non_finite_returns_error() {
1473        assert!(LorentzTransform::from_velocity(f64::INFINITY, 1.0).is_err());
1474        assert!(LorentzTransform::from_velocity(0.5, f64::NAN).is_err());
1475    }
1476
1477    #[test]
1478    fn test_from_velocity_superluminal_returns_error() {
1479        // v > c → |beta| > 1 → should fail
1480        assert!(LorentzTransform::from_velocity(1.5, 1.0).is_err());
1481    }
1482
1483    // ── LorentzTransform::relativistic_momentum ───────────────────────────────
1484
1485    #[test]
1486    fn test_relativistic_momentum_zero_beta_is_zero() {
1487        let lt = LorentzTransform::new(0.0).unwrap();
1488        assert!(approx_eq(lt.relativistic_momentum(1.0), 0.0));
1489    }
1490
1491    #[test]
1492    fn test_relativistic_momentum_known_value() {
1493        // beta=0.6, gamma=1.25 → p = mass * gamma * beta = 2.0 * 1.25 * 0.6 = 1.5
1494        let lt = LorentzTransform::new(0.6).unwrap();
1495        assert!((lt.relativistic_momentum(2.0) - 1.5).abs() < 1e-9);
1496    }
1497
1498    #[test]
1499    fn test_relativistic_momentum_scales_with_mass() {
1500        let lt = LorentzTransform::new(0.6).unwrap();
1501        let p1 = lt.relativistic_momentum(1.0);
1502        let p2 = lt.relativistic_momentum(2.0);
1503        assert!((p2 - 2.0 * p1).abs() < 1e-9);
1504    }
1505
1506    // ── LorentzTransform::kinetic_energy_ratio ────────────────────────────────
1507
1508    #[test]
1509    fn test_kinetic_energy_ratio_zero_at_rest() {
1510        let lt = LorentzTransform::new(0.0).unwrap();
1511        assert!(approx_eq(lt.kinetic_energy_ratio(), 0.0));
1512    }
1513
1514    #[test]
1515    fn test_kinetic_energy_ratio_known_value() {
1516        // beta=0.6 → gamma=1.25 → ratio = 1.25 - 1 = 0.25
1517        let lt = LorentzTransform::new(0.6).unwrap();
1518        assert!((lt.kinetic_energy_ratio() - 0.25).abs() < 1e-9);
1519    }
1520
1521    #[test]
1522    fn test_kinetic_energy_ratio_positive_for_nonzero_beta() {
1523        let lt = LorentzTransform::new(0.8).unwrap();
1524        assert!(lt.kinetic_energy_ratio() > 0.0);
1525    }
1526
1527    #[test]
1528    fn test_composition_zero_with_zero_is_zero() {
1529        let t1 = LorentzTransform::new(0.0).unwrap();
1530        let t2 = LorentzTransform::new(0.0).unwrap();
1531        let composed = t1.composition(&t2).unwrap();
1532        assert!(composed.beta().abs() < 1e-12);
1533    }
1534
1535    #[test]
1536    fn test_composition_with_opposite_is_near_zero() {
1537        let t1 = LorentzTransform::new(0.6).unwrap();
1538        let t2 = t1.inverse(); // beta = -0.6
1539        let composed = t1.composition(&t2).unwrap();
1540        // (0.6 + (-0.6)) / (1 + 0.6*(-0.6)) = 0 / 0.64 = 0
1541        assert!(composed.beta().abs() < 1e-12);
1542    }
1543
1544    #[test]
1545    fn test_composition_velocity_addition_known_value() {
1546        // 0.5c + 0.5c = (0.5+0.5)/(1+0.25) = 1.0/1.25 = 0.8
1547        let t1 = LorentzTransform::new(0.5).unwrap();
1548        let t2 = LorentzTransform::new(0.5).unwrap();
1549        let composed = t1.composition(&t2).unwrap();
1550        assert!((composed.beta() - 0.8).abs() < 1e-12);
1551    }
1552
1553    // ── LorentzTransform::time_dilation_factor ────────────────────────────────
1554
1555    #[test]
1556    fn test_time_dilation_factor_one_at_rest() {
1557        let t = LorentzTransform::new(0.0).unwrap();
1558        assert!((t.time_dilation_factor() - 1.0).abs() < 1e-12);
1559    }
1560
1561    #[test]
1562    fn test_time_dilation_factor_equals_gamma() {
1563        let t = LorentzTransform::new(0.6).unwrap();
1564        assert!((t.time_dilation_factor() - t.gamma()).abs() < 1e-12);
1565    }
1566
1567    #[test]
1568    fn test_time_dilation_factor_greater_than_one_for_nonzero_beta() {
1569        let t = LorentzTransform::new(0.8).unwrap();
1570        assert!(t.time_dilation_factor() > 1.0);
1571    }
1572
1573    // --- time_contraction ---
1574
1575    #[test]
1576    fn test_time_contraction_inverse_of_dilate_time() {
1577        let t = LorentzTransform::new(0.6).unwrap();
1578        let original = 100.0_f64;
1579        let dilated = t.dilate_time(original);
1580        let contracted = t.time_contraction(dilated);
1581        assert!((contracted - original).abs() < 1e-10);
1582    }
1583
1584    #[test]
1585    fn test_time_contraction_at_zero_beta_equals_input() {
1586        let t = LorentzTransform::new(0.0).unwrap();
1587        assert!((t.time_contraction(42.0) - 42.0).abs() < 1e-12);
1588    }
1589
1590    #[test]
1591    fn test_time_contraction_less_than_input_for_nonzero_beta() {
1592        let t = LorentzTransform::new(0.8).unwrap();
1593        assert!(t.time_contraction(100.0) < 100.0);
1594    }
1595
1596    // ── LorentzTransform::length_contraction_factor ───────────────────────────
1597
1598    #[test]
1599    fn test_length_contraction_factor_one_at_rest() {
1600        let t = LorentzTransform::new(0.0).unwrap();
1601        assert!((t.length_contraction_factor() - 1.0).abs() < 1e-12);
1602    }
1603
1604    #[test]
1605    fn test_length_contraction_factor_is_reciprocal_of_gamma() {
1606        let t = LorentzTransform::new(0.6).unwrap();
1607        assert!((t.length_contraction_factor() - 1.0 / t.gamma()).abs() < 1e-12);
1608    }
1609
1610    #[test]
1611    fn test_length_contraction_factor_less_than_one_for_nonzero_beta() {
1612        let t = LorentzTransform::new(0.9).unwrap();
1613        assert!(t.length_contraction_factor() < 1.0);
1614    }
1615
1616    // ── LorentzTransform::proper_velocity ────────────────────────────────────
1617
1618    #[test]
1619    fn test_proper_velocity_zero_at_rest() {
1620        let t = LorentzTransform::new(0.0).unwrap();
1621        assert!((t.proper_velocity() - 0.0).abs() < 1e-12);
1622    }
1623
1624    #[test]
1625    fn test_proper_velocity_equals_beta_times_gamma() {
1626        let t = LorentzTransform::new(0.6).unwrap();
1627        assert!((t.proper_velocity() - t.beta() * t.gamma()).abs() < 1e-12);
1628    }
1629
1630    #[test]
1631    fn test_proper_velocity_greater_than_beta_for_high_speed() {
1632        // At v=0.8c, gamma≈1.667, so proper_velocity = 0.8*1.667 ≈ 1.333 > 0.8
1633        let t = LorentzTransform::new(0.8).unwrap();
1634        assert!(t.proper_velocity() > t.beta());
1635    }
1636
1637    // --- is_ultrarelativistic / momentum_factor ---
1638
1639    #[test]
1640    fn test_is_ultrarelativistic_true_above_0_9() {
1641        let t = LorentzTransform::new(0.95).unwrap();
1642        assert!(t.is_ultrarelativistic());
1643    }
1644
1645    #[test]
1646    fn test_is_ultrarelativistic_false_below_0_9() {
1647        let t = LorentzTransform::new(0.5).unwrap();
1648        assert!(!t.is_ultrarelativistic());
1649    }
1650
1651    #[test]
1652    fn test_is_ultrarelativistic_false_at_exactly_0_9() {
1653        let t = LorentzTransform::new(0.9).unwrap();
1654        assert!(!t.is_ultrarelativistic()); // strictly greater than 0.9
1655    }
1656
1657    #[test]
1658    fn test_momentum_factor_zero_at_rest() {
1659        let t = LorentzTransform::new(0.0).unwrap();
1660        assert!(t.momentum_factor().abs() < 1e-12);
1661    }
1662
1663    #[test]
1664    fn test_momentum_factor_equals_gamma_times_beta() {
1665        let t = LorentzTransform::new(0.6).unwrap();
1666        assert!((t.momentum_factor() - t.gamma() * t.beta()).abs() < 1e-12);
1667    }
1668
1669    // --- relativistic_energy ---
1670
1671    #[test]
1672    fn test_relativistic_energy_equals_rest_mass_at_rest() {
1673        let t = LorentzTransform::new(0.0).unwrap();
1674        assert!((t.relativistic_energy(1.0) - 1.0).abs() < 1e-12);
1675    }
1676
1677    #[test]
1678    fn test_relativistic_energy_greater_than_rest_mass_for_moving_particle() {
1679        let t = LorentzTransform::new(0.6).unwrap();
1680        assert!(t.relativistic_energy(1.0) > 1.0);
1681    }
1682
1683    #[test]
1684    fn test_relativistic_energy_equals_gamma_times_rest_mass() {
1685        let t = LorentzTransform::new(0.8).unwrap();
1686        let m = 2.5;
1687        assert!((t.relativistic_energy(m) - t.gamma() * m).abs() < 1e-12);
1688    }
1689
1690    // --- kinetic_energy ---
1691
1692    #[test]
1693    fn test_kinetic_energy_zero_at_rest() {
1694        let t = LorentzTransform::new(0.0).unwrap();
1695        assert!(t.kinetic_energy(1.0).abs() < 1e-12);
1696    }
1697
1698    #[test]
1699    fn test_kinetic_energy_positive_for_moving_particle() {
1700        let t = LorentzTransform::new(0.6).unwrap();
1701        assert!(t.kinetic_energy(1.0) > 0.0);
1702    }
1703
1704    #[test]
1705    fn test_kinetic_energy_equals_total_minus_rest() {
1706        let t = LorentzTransform::new(0.8).unwrap();
1707        let m = 3.0;
1708        let ke = t.kinetic_energy(m);
1709        let total = t.relativistic_energy(m);
1710        assert!((ke - (total - m)).abs() < 1e-12);
1711    }
1712
1713    // --- four_velocity_time ---
1714
1715    #[test]
1716    fn test_four_velocity_time_equals_gamma() {
1717        let t = LorentzTransform::new(0.6).unwrap();
1718        assert!((t.four_velocity_time() - t.gamma()).abs() < 1e-12);
1719    }
1720
1721    #[test]
1722    fn test_four_velocity_time_one_at_rest() {
1723        let t = LorentzTransform::new(0.0).unwrap();
1724        assert!((t.four_velocity_time() - 1.0).abs() < 1e-12);
1725    }
1726
1727    #[test]
1728    fn test_four_velocity_time_greater_than_one_when_moving() {
1729        let t = LorentzTransform::new(0.5).unwrap();
1730        assert!(t.four_velocity_time() > 1.0);
1731    }
1732
1733    // --- LorentzTransform::proper_time_dilation ---
1734    #[test]
1735    fn test_proper_time_dilation_at_rest_equals_dt() {
1736        let t = LorentzTransform::new(0.0).unwrap(); // gamma = 1
1737        assert!((t.proper_time_dilation(10.0) - 10.0).abs() < 1e-10);
1738    }
1739
1740    #[test]
1741    fn test_proper_time_dilation_less_than_dt_when_moving() {
1742        let t = LorentzTransform::new(0.6).unwrap(); // gamma ≈ 1.25
1743        let proper = t.proper_time_dilation(10.0);
1744        assert!(proper < 10.0, "moving clock should run slow: {proper}");
1745    }
1746
1747    #[test]
1748    fn test_proper_time_dilation_approaches_zero_at_high_speed() {
1749        let t = LorentzTransform::new(0.9999).unwrap();
1750        let proper = t.proper_time_dilation(1.0);
1751        assert!(proper < 0.02, "expected near-zero proper time at 0.9999c: {proper}");
1752    }
1753
1754    // --- LorentzTransform::beta_from_rapidity ---
1755    #[test]
1756    fn test_beta_from_rapidity_zero_rapidity_gives_zero_beta() {
1757        assert!((LorentzTransform::beta_from_rapidity(0.0)).abs() < 1e-12);
1758    }
1759
1760    #[test]
1761    fn test_beta_from_rapidity_roundtrip_with_rapidity() {
1762        let t = LorentzTransform::new(0.7).unwrap();
1763        let eta = t.rapidity();
1764        let beta = LorentzTransform::beta_from_rapidity(eta);
1765        assert!((beta - 0.7).abs() < 1e-10, "roundtrip failed: {beta}");
1766    }
1767
1768    #[test]
1769    fn test_beta_from_rapidity_bounded_below_one() {
1770        // Rapidity of 10 → very close to c but below 1.0
1771        let beta = LorentzTransform::beta_from_rapidity(10.0);
1772        assert!(beta < 1.0 && beta > 0.9999);
1773    }
1774
1775    // ── LorentzTransform::rapidity ────────────────────────────────────────────
1776
1777    #[test]
1778    fn test_rapidity_zero_at_rest() {
1779        let t = LorentzTransform::new(0.0).unwrap();
1780        assert!(t.rapidity().abs() < 1e-12);
1781    }
1782
1783    #[test]
1784    fn test_rapidity_equals_atanh_beta() {
1785        let t = LorentzTransform::new(0.5).unwrap();
1786        assert!((t.rapidity() - 0.5_f64.atanh()).abs() < 1e-12);
1787    }
1788
1789    #[test]
1790    fn test_rapidity_positive_for_nonzero_beta() {
1791        let t = LorentzTransform::new(0.8).unwrap();
1792        assert!(t.rapidity() > 0.0);
1793    }
1794
1795    // ── LorentzTransform::doppler_factor ──────────────────────────────────────
1796
1797    #[test]
1798    fn test_doppler_factor_one_at_rest() {
1799        let t = LorentzTransform::new(0.0).unwrap();
1800        assert!((t.doppler_factor() - 1.0).abs() < 1e-12);
1801    }
1802
1803    #[test]
1804    fn test_doppler_factor_greater_than_one_when_approaching() {
1805        let t = LorentzTransform::new(0.6).unwrap();
1806        // sqrt((1.6)/(0.4)) = sqrt(4) = 2
1807        assert!((t.doppler_factor() - 2.0).abs() < 1e-10);
1808    }
1809
1810    #[test]
1811    fn test_doppler_factor_inverse_of_receding() {
1812        let t1 = LorentzTransform::new(0.6).unwrap();
1813        let t2 = LorentzTransform::new(0.6).unwrap();
1814        // Forward and inverse doppler factors multiply to 1
1815        let fwd = t1.doppler_factor();
1816        let rev = 1.0 / t2.doppler_factor();
1817        assert!((fwd - 1.0 / rev).abs() < 1e-10);
1818    }
1819
1820    // ── LorentzTransform::aberration_angle ────────────────────────────────────
1821
1822    #[test]
1823    fn test_aberration_angle_at_rest_unchanged() {
1824        let t = LorentzTransform::new(0.0).unwrap();
1825        let angle_in = std::f64::consts::PI / 3.0; // 60°
1826        let cos_in = angle_in.cos();
1827        let angle_out = t.aberration_angle(cos_in);
1828        assert!((angle_out - angle_in).abs() < 1e-12);
1829    }
1830
1831    #[test]
1832    fn test_aberration_angle_head_on_unchanged() {
1833        let t = LorentzTransform::new(0.5).unwrap();
1834        // cos(0) = 1 → head-on → still 0 angle
1835        let angle_out = t.aberration_angle(1.0);
1836        assert!(angle_out.abs() < 1e-12);
1837    }
1838
1839    #[test]
1840    fn test_aberration_angle_range_zero_to_pi() {
1841        let t = LorentzTransform::new(0.8).unwrap();
1842        let angle = t.aberration_angle(0.0);
1843        assert!(angle >= 0.0 && angle <= std::f64::consts::PI);
1844    }
1845
1846    // ── LorentzTransform::relativistic_mass / energy_ratio ──────────────────
1847
1848    #[test]
1849    fn test_relativistic_mass_at_rest_equals_rest_mass() {
1850        let t = LorentzTransform::new(0.0).unwrap();
1851        assert!((t.relativistic_mass(1.0) - 1.0).abs() < 1e-12);
1852    }
1853
1854    #[test]
1855    fn test_relativistic_mass_increases_with_speed() {
1856        let t = LorentzTransform::new(0.6).unwrap();
1857        // gamma(0.6) = 1/sqrt(1-0.36) = 1/sqrt(0.64) = 1/0.8 = 1.25
1858        assert!((t.relativistic_mass(1.0) - 1.25).abs() < 1e-10);
1859    }
1860
1861    #[test]
1862    fn test_energy_ratio_zero_at_rest() {
1863        let t = LorentzTransform::new(0.0).unwrap();
1864        assert!(t.energy_ratio().abs() < 1e-12);
1865    }
1866
1867    #[test]
1868    fn test_energy_ratio_positive_for_nonzero_beta() {
1869        let t = LorentzTransform::new(0.6).unwrap();
1870        // gamma(0.6) = 1.25 → energy_ratio = 0.25
1871        assert!((t.energy_ratio() - 0.25).abs() < 1e-10);
1872    }
1873
1874    // ── LorentzTransform::momentum_ratio / is_ultra_relativistic ────────────
1875
1876    #[test]
1877    fn test_momentum_ratio_zero_at_rest() {
1878        let t = LorentzTransform::new(0.0).unwrap();
1879        assert!(t.momentum_ratio().abs() < 1e-12);
1880    }
1881
1882    #[test]
1883    fn test_momentum_ratio_correct_at_0_6() {
1884        let t = LorentzTransform::new(0.6).unwrap();
1885        // gamma=1.25, beta=0.6 → momentum_ratio = 0.75
1886        assert!((t.momentum_ratio() - 0.75).abs() < 1e-10);
1887    }
1888
1889    #[test]
1890    fn test_is_ultra_relativistic_true_above_0_9() {
1891        let t = LorentzTransform::new(0.95).unwrap();
1892        assert!(t.is_ultra_relativistic());
1893    }
1894
1895    #[test]
1896    fn test_is_ultra_relativistic_false_below_0_9() {
1897        let t = LorentzTransform::new(0.8).unwrap();
1898        assert!(!t.is_ultra_relativistic());
1899    }
1900
1901    #[test]
1902    fn test_is_ultra_relativistic_false_at_rest() {
1903        let t = LorentzTransform::new(0.0).unwrap();
1904        assert!(!t.is_ultra_relativistic());
1905    }
1906
1907    // ── LorentzTransform::lorentz_factor_approx ──────────────────────────────
1908
1909    #[test]
1910    fn test_lorentz_factor_approx_equals_one_at_rest() {
1911        let t = LorentzTransform::new(0.0).unwrap();
1912        assert!((t.lorentz_factor_approx() - 1.0).abs() < 1e-12);
1913    }
1914
1915    #[test]
1916    fn test_lorentz_factor_approx_close_for_small_beta() {
1917        let t = LorentzTransform::new(0.1).unwrap();
1918        // Exact gamma = 1/sqrt(1-0.01) ≈ 1.005037, approx = 1 + 0.005 = 1.005
1919        let approx = t.lorentz_factor_approx();
1920        let exact = t.gamma();
1921        assert!((approx - exact).abs() < 0.001);
1922    }
1923
1924    #[test]
1925    fn test_lorentz_factor_approx_always_gte_one() {
1926        let t = LorentzTransform::new(0.5).unwrap();
1927        assert!(t.lorentz_factor_approx() >= 1.0);
1928    }
1929
1930    // ── LorentzTransform::velocity_ratio ─────────────────────────────────────
1931
1932    #[test]
1933    fn test_velocity_ratio_equals_one_for_same_beta() {
1934        let t = LorentzTransform::new(0.6).unwrap();
1935        assert!((t.velocity_ratio(&t) - 1.0).abs() < 1e-12);
1936    }
1937
1938    #[test]
1939    fn test_velocity_ratio_correct() {
1940        let a = LorentzTransform::new(0.6).unwrap();
1941        let b = LorentzTransform::new(0.3).unwrap();
1942        assert!((a.velocity_ratio(&b) - 2.0).abs() < 1e-12);
1943    }
1944
1945    #[test]
1946    fn test_velocity_ratio_less_than_one_when_slower() {
1947        let a = LorentzTransform::new(0.3).unwrap();
1948        let b = LorentzTransform::new(0.6).unwrap();
1949        assert!(a.velocity_ratio(&b) < 1.0);
1950    }
1951
1952    // ── LorentzTransform::proper_length ──────────────────────────────────────
1953
1954    #[test]
1955    fn test_proper_length_at_rest_equals_observed() {
1956        let t = LorentzTransform::new(0.0).unwrap();
1957        // beta=0 → gamma=1 → proper_length = observed
1958        assert!((t.proper_length(10.0) - 10.0).abs() < 1e-10);
1959    }
1960
1961    #[test]
1962    fn test_proper_length_greater_than_observed_when_moving() {
1963        let t = LorentzTransform::new(0.6).unwrap();
1964        // gamma=1.25 → proper_length = 1.25 * observed
1965        assert!((t.proper_length(8.0) - 10.0).abs() < 1e-9);
1966    }
1967
1968    // ── LorentzTransform::length_contraction ─────────────────────────────────
1969
1970    #[test]
1971    fn test_length_contraction_at_rest_equals_rest_length() {
1972        let t = LorentzTransform::new(0.0).unwrap();
1973        assert!((t.length_contraction(10.0) - 10.0).abs() < 1e-10);
1974    }
1975
1976    #[test]
1977    fn test_length_contraction_shorter_when_moving() {
1978        let t = LorentzTransform::new(0.6).unwrap();
1979        // gamma=1.25 → contracted = 10/1.25 = 8
1980        assert!((t.length_contraction(10.0) - 8.0).abs() < 1e-9);
1981    }
1982
1983    #[test]
1984    fn test_length_contraction_proper_length_roundtrip() {
1985        let t = LorentzTransform::new(0.8).unwrap();
1986        let rest = 100.0;
1987        let contracted = t.length_contraction(rest);
1988        let recovered = t.proper_length(contracted);
1989        assert!((recovered - rest).abs() < 1e-9);
1990    }
1991
1992    // ── beta_times_gamma ──────────────────────────────────────────────────────
1993
1994    #[test]
1995    fn test_beta_times_gamma_zero_at_rest() {
1996        let t = LorentzTransform::new(0.0).unwrap();
1997        assert_eq!(t.beta_times_gamma(), 0.0);
1998    }
1999
2000    #[test]
2001    fn test_beta_times_gamma_positive_for_nonzero_velocity() {
2002        let t = LorentzTransform::new(0.6).unwrap();
2003        // beta=0.6, gamma=1.25 → beta*gamma=0.75
2004        let bg = t.beta_times_gamma();
2005        assert!((bg - 0.75).abs() < 1e-9);
2006    }
2007
2008    #[test]
2009    fn test_beta_times_gamma_increases_with_velocity() {
2010        let t1 = LorentzTransform::new(0.5).unwrap();
2011        let t2 = LorentzTransform::new(0.9).unwrap();
2012        assert!(t2.beta_times_gamma() > t1.beta_times_gamma());
2013    }
2014
2015    // ── energy_momentum_invariant ─────────────────────────────────────────────
2016
2017    #[test]
2018    fn test_energy_momentum_invariant_equals_mass_squared_at_rest() {
2019        let t = LorentzTransform::new(0.0).unwrap();
2020        let mass = 2.0;
2021        let inv = t.energy_momentum_invariant(mass);
2022        assert!((inv - mass * mass).abs() < 1e-9);
2023    }
2024
2025    #[test]
2026    fn test_energy_momentum_invariant_equals_mass_squared_at_velocity() {
2027        let t = LorentzTransform::new(0.8).unwrap();
2028        let mass = 3.0;
2029        let inv = t.energy_momentum_invariant(mass);
2030        assert!((inv - mass * mass).abs() < 1e-9);
2031    }
2032}