@@ -195,6 +195,14 @@ pub enum FeatureTransform {
195195 /// needed). Added 2026-05-18 for Gain-MLP-style per-pixel MLPs
196196 /// (Canham et al. 2025).
197197 Sinusoidal ,
198+ /// `x / (scale + |x|)` — smooth, strictly monotone, *saturating* soft-sign.
199+ /// Tames heavy-tail/corruption extremes like a winsor clip but with a
200+ /// bounded, CONTINUOUS derivative (`scale/(scale+|x|)²`), so a model's
201+ /// gradient through it stays clean for the runtime diffmap (winsor's
202+ /// clip and `quantile_bins`' step do not). One optional parameter, the
203+ /// `scale` (saturation onset); `params = []` uses `scale = 1`.
204+ /// Added 2026-07-24 for the smooth-saturating "ideal" zensim model.
205+ SoftSign ,
198206}
199207
200208/// Two-pi constant for sinusoidal embedding. Mirrors `core::f32::consts::TAU`
@@ -232,6 +240,24 @@ fn signed_cbrt(x: f32) -> f32 {
232240 }
233241}
234242
243+ /// Soft-sign `x / (scale + |x|)` — smooth, strictly monotone, and *saturating*
244+ /// (→ ±1). `scale > 0` sets the saturation onset: for `|x| ≪ scale` it is
245+ /// ~linear (`x/scale`), for `|x| ≫ scale` it flattens toward ±1. Its
246+ /// derivative `scale / (scale + |x|)²` is bounded and CONTINUOUS everywhere —
247+ /// unlike `winsor`'s clip (0-a.e. with a kink) or `quantile_bins`' step — so a
248+ /// model's central-difference gradient through it stays clean. That is what
249+ /// lets it tame corruption/heavy-tail extremes (like winsor) WITHOUT breaking
250+ /// the diffmap `s_k` (like raw): the smooth-saturating middle the ideal
251+ /// zensim model needs. `scale ≤ 0` falls back to `scale = 1`.
252+ fn soft_sign ( x : f32 , scale : f32 ) -> f32 {
253+ let s = if scale > 0.0 { scale } else { 1.0 } ;
254+ #[ cfg( feature = "std" ) ]
255+ let ax = x. abs ( ) ;
256+ #[ cfg( not( feature = "std" ) ) ]
257+ let ax = libm:: fabsf ( x) ;
258+ x / ( s + ax)
259+ }
260+
235261/// Yeo-Johnson power transform. Smooth across the entire real line;
236262/// `λ = 1` reduces to a constant-shifted identity (`y = x`), `λ = 0`
237263/// falls to `ln(1 + x)` for non-negative input, `λ = 2` falls to
@@ -425,6 +451,7 @@ impl FeatureTransform {
425451 // invariant other variants follow).
426452 Self :: YeoJohnson => x,
427453 Self :: WinsorP99 | Self :: QuantileBins => x,
454+ Self :: SoftSign => soft_sign ( x, 1.0 ) ,
428455 // Scalar `apply` cannot represent an expander variant. The
429456 // bake-load + runtime paths route through
430457 // `apply_expanding` / `apply_feature_pipeline_expanding` /
@@ -583,6 +610,7 @@ impl FeatureTransform {
583610 } ;
584611 yeo_johnson ( x, lambda)
585612 }
613+ Self :: SoftSign => soft_sign ( x, params. first ( ) . copied ( ) . unwrap_or ( 1.0 ) ) ,
586614 // Sinusoidal is an expander — see `apply` for the rationale
587615 // on why this panics instead of returning a degenerate scalar.
588616 Self :: Sinusoidal => panic ! (
@@ -720,6 +748,7 @@ impl FeatureTransform {
720748 "clip_then_log1p_then_winsor" => Ok ( Self :: ClipThenLog1pThenWinsor ) ,
721749 "yeo_johnson" => Ok ( Self :: YeoJohnson ) ,
722750 "sinusoidal" => Ok ( Self :: Sinusoidal ) ,
751+ "soft_sign" => Ok ( Self :: SoftSign ) ,
723752 _ => Err ( PredictError :: UnknownFeatureTransform ) ,
724753 }
725754 }
@@ -743,6 +772,7 @@ impl FeatureTransform {
743772 Self :: ClipThenLog1pThenWinsor => "clip_then_log1p_then_winsor" ,
744773 Self :: YeoJohnson => "yeo_johnson" ,
745774 Self :: Sinusoidal => "sinusoidal" ,
775+ Self :: SoftSign => "soft_sign" ,
746776 }
747777 }
748778}
@@ -1431,7 +1461,7 @@ mod tests {
14311461 /// Returns every FeatureTransform variant currently in the enum.
14321462 /// Keep this in sync with the enum when adding new variants —
14331463 /// the universal tests below iterate this list.
1434- fn all_variants ( ) -> [ FeatureTransform ; 15 ] {
1464+ fn all_variants ( ) -> [ FeatureTransform ; 16 ] {
14351465 [
14361466 FeatureTransform :: Identity ,
14371467 FeatureTransform :: Log ,
@@ -1448,6 +1478,7 @@ mod tests {
14481478 FeatureTransform :: SignedCbrtThenWinsor ,
14491479 FeatureTransform :: ClipThenLog1pThenWinsor ,
14501480 FeatureTransform :: YeoJohnson ,
1481+ FeatureTransform :: SoftSign ,
14511482 ]
14521483 }
14531484
@@ -1471,6 +1502,10 @@ mod tests {
14711502 FeatureTransform :: SignedCbrtThenWinsor => & [ -2.0_f32 , 2.0 ] ,
14721503 FeatureTransform :: ClipThenLog1pThenWinsor => & [ 0.1_f32 , 0.0 , 3.0 ] ,
14731504 FeatureTransform :: YeoJohnson => & [ -50.0_f32 ] ,
1505+ // SoftSign takes an optional scale param; the no-param
1506+ // fallback (scale = 1) is exercised by all_variants(), so a
1507+ // non-trivial scale here checks apply_with_params too.
1508+ FeatureTransform :: SoftSign => & [ 2.0_f32 ] ,
14741509 // Sinusoidal is an expander (panics under scalar apply),
14751510 // so it is excluded from `all_variants()` and never reaches
14761511 // these scalar NaN-safety helpers. This arm only keeps the
@@ -1602,6 +1637,62 @@ mod tests {
16021637 }
16031638 }
16041639
1640+ // -----------------------------------------------------------------
1641+ // SoftSign (smooth saturating transform for coherent diffmaps)
1642+ // -----------------------------------------------------------------
1643+
1644+ #[ test]
1645+ fn soft_sign_token_round_trip_and_flags ( ) {
1646+ let t = FeatureTransform :: from_token ( "soft_sign" ) . expect ( "parse" ) ;
1647+ assert_eq ! ( t, FeatureTransform :: SoftSign ) ;
1648+ assert_eq ! ( t. as_token( ) , "soft_sign" ) ;
1649+ assert ! ( !t. requires_params( ) , "scale is optional (default 1)" ) ;
1650+ assert ! ( !t. is_expander( ) ) ;
1651+ assert_eq ! ( t. output_arity( & [ ] ) , 1 ) ;
1652+ assert_eq ! ( t. output_arity( & [ 2.0 ] ) , 1 ) ;
1653+ }
1654+
1655+ #[ test]
1656+ fn soft_sign_is_odd_monotone_saturating_and_matches_formula ( ) {
1657+ let f = |x : f32 | FeatureTransform :: SoftSign . apply ( x) ;
1658+ // Reference: x / (1 + |x|).
1659+ for & x in & [ -1000.0_f32 , -3.5 , -1.0 , -0.1 , 0.0 , 0.1 , 1.0 , 3.5 , 1000.0 ] {
1660+ let want = x / ( 1.0 + x. abs ( ) ) ;
1661+ assert ! ( ( f( x) - want) . abs( ) < 1e-6 , "soft_sign({x})={} want {want}" , f( x) ) ;
1662+ }
1663+ // Odd: f(-x) = -f(x).
1664+ for & x in & [ 0.3_f32 , 1.7 , 42.0 ] {
1665+ assert ! ( ( f( -x) + f( x) ) . abs( ) < 1e-6 ) ;
1666+ }
1667+ // Strictly monotone increasing.
1668+ let mut prev = f ( -1e6 ) ;
1669+ for i in -1000 ..=1000 {
1670+ let cur = f ( i as f32 * 0.05 ) ;
1671+ assert ! ( cur > prev, "not monotone at {i}" ) ;
1672+ prev = cur;
1673+ }
1674+ // Bounded in [-1, 1] — the saturation that tames heavy tails.
1675+ // (At x≫1/eps the f32 `+1` is lost and it saturates to exactly 1.)
1676+ assert ! ( f( 1000.0 ) < 1.0 && f( 1000.0 ) > 0.999 ) ;
1677+ assert ! ( f( -1000.0 ) > -1.0 && f( -1000.0 ) < -0.999 ) ;
1678+ assert ! ( f( 1e9 ) <= 1.0 && f( -1e9 ) >= -1.0 ) ;
1679+ assert_eq ! ( f( 0.0 ) , 0.0 ) ;
1680+ }
1681+
1682+ #[ test]
1683+ fn soft_sign_scale_param_widens_the_linear_zone ( ) {
1684+ // Larger scale → gentler saturation: |soft_sign_s(x)| < |soft_sign_1(x)|
1685+ // for the same x, and the derivative at 0 is 1/scale.
1686+ let s1 = FeatureTransform :: SoftSign . apply_with_params ( 2.0 , & [ ] ) ;
1687+ let s4 = FeatureTransform :: SoftSign . apply_with_params ( 2.0 , & [ 4.0 ] ) ;
1688+ assert ! ( ( s1 - 2.0 / 3.0 ) . abs( ) < 1e-6 ) ; // 2/(1+2)
1689+ assert ! ( ( s4 - 2.0 / 6.0 ) . abs( ) < 1e-6 ) ; // 2/(4+2)
1690+ assert ! ( s4. abs( ) < s1. abs( ) ) ;
1691+ // Non-positive scale falls back to 1 (guards a garbage screen entry).
1692+ let s0 = FeatureTransform :: SoftSign . apply_with_params ( 2.0 , & [ 0.0 ] ) ;
1693+ assert ! ( ( s0 - s1) . abs( ) < 1e-6 ) ;
1694+ }
1695+
16051696 // -----------------------------------------------------------------
16061697 // Sinusoidal embedding (expander variant)
16071698 // -----------------------------------------------------------------
0 commit comments