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