@@ -203,6 +203,17 @@ pub enum FeatureTransform {
203203 /// `scale` (saturation onset); `params = []` uses `scale = 1`.
204204 /// Added 2026-07-24 for the smooth-saturating "ideal" zensim model.
205205 SoftSign ,
206+ /// Smooth soft-clip (Huber-log winsor): IDENTITY for `|x| ≤ knee`, then a
207+ /// concave, strictly-monotone, UNBOUNDED log tail — `C¹`-matched at the
208+ /// knee (slope 1 on both sides), so no kink/flat/step. Unlike `SoftSign`
209+ /// the tail is UNBOUNDED, so it preserves the negative-tail ORDER the
210+ /// corruption gate needs (a pathological feature stays > any honest one);
211+ /// unlike `WinsorP99` it is smooth (no 0-derivative flat), so the runtime
212+ /// diffmap `s_k` stays clean; unlike `SignedCbrt` the honest in-range bulk
213+ /// passes through undistorted (identity core → better FR discrimination).
214+ /// Two optional params `[knee, soft]` (defaults `1, 1`); each `≤ 0` falls
215+ /// back to 1. Added 2026-07-24 as the frontier clean-model transform.
216+ SoftClip ,
206217}
207218
208219/// Two-pi constant for sinusoidal embedding. Mirrors `core::f32::consts::TAU`
@@ -258,6 +269,42 @@ fn soft_sign(x: f32, scale: f32) -> f32 {
258269 x / ( s + ax)
259270}
260271
272+ /// Soft-clip (smooth "Huber-log winsor"): IDENTITY inside `|x| ≤ knee`, then a
273+ /// concave, strictly-monotone, UNBOUNDED log tail outside it —
274+ /// `f(x) = x` for `|x| ≤ knee`
275+ /// `f(x) = sign(x)·(knee + soft·ln(1 + (|x|−knee)/soft))` for `|x| > knee`
276+ /// The tail is `C¹`-matched at the knee (both sides have slope 1), so `f` is
277+ /// smooth everywhere (continuous derivative — no kink, no flat, no step). Two
278+ /// properties the corruption/diffmap gauntlet needs, that neither `winsor`
279+ /// (flat tail → 0-derivative → breaks the diffmap `s_k`) nor `soft_sign`
280+ /// (BOUNDED tail → saturates corruption to the same value as large honest
281+ /// features → destroys the negative-tail ORDER) provides:
282+ /// 1. **identity core** — honest in-range features pass through undistorted,
283+ /// so honest FR discrimination is preserved (better than `signed_cbrt`,
284+ /// which compresses even the bulk);
285+ /// 2. **unbounded monotone tail** — a pathological (corruption) feature stays
286+ /// strictly larger than any honest one, so `score(corruption) < score(q20)`
287+ /// holds; the log just shrinks the margin, never the order.
288+ /// `knee ≤ 0` falls back to `knee = 1`; `soft ≤ 0` falls back to `soft = 1`.
289+ fn soft_clip ( x : f32 , knee : f32 , soft : f32 ) -> f32 {
290+ let k = if knee > 0.0 { knee } else { 1.0 } ;
291+ let s = if soft > 0.0 { soft } else { 1.0 } ;
292+ let sign = if x >= 0.0 { 1.0 } else { -1.0 } ;
293+ #[ cfg( feature = "std" ) ]
294+ let ax = x. abs ( ) ;
295+ #[ cfg( not( feature = "std" ) ) ]
296+ let ax = libm:: fabsf ( x) ;
297+ if ax <= k {
298+ x
299+ } else {
300+ #[ cfg( feature = "std" ) ]
301+ let tail = s * ( ( ax - k) / s) . ln_1p ( ) ;
302+ #[ cfg( not( feature = "std" ) ) ]
303+ let tail = s * libm:: log1pf ( ( ax - k) / s) ;
304+ sign * ( k + tail)
305+ }
306+ }
307+
261308/// Yeo-Johnson power transform. Smooth across the entire real line;
262309/// `λ = 1` reduces to a constant-shifted identity (`y = x`), `λ = 0`
263310/// falls to `ln(1 + x)` for non-negative input, `λ = 2` falls to
@@ -452,6 +499,7 @@ impl FeatureTransform {
452499 Self :: YeoJohnson => x,
453500 Self :: WinsorP99 | Self :: QuantileBins => x,
454501 Self :: SoftSign => soft_sign ( x, 1.0 ) ,
502+ Self :: SoftClip => soft_clip ( x, 1.0 , 1.0 ) ,
455503 // Scalar `apply` cannot represent an expander variant. The
456504 // bake-load + runtime paths route through
457505 // `apply_expanding` / `apply_feature_pipeline_expanding` /
@@ -611,6 +659,11 @@ impl FeatureTransform {
611659 yeo_johnson ( x, lambda)
612660 }
613661 Self :: SoftSign => soft_sign ( x, params. first ( ) . copied ( ) . unwrap_or ( 1.0 ) ) ,
662+ Self :: SoftClip => soft_clip (
663+ x,
664+ params. first ( ) . copied ( ) . unwrap_or ( 1.0 ) ,
665+ params. get ( 1 ) . copied ( ) . unwrap_or ( 1.0 ) ,
666+ ) ,
614667 // Sinusoidal is an expander — see `apply` for the rationale
615668 // on why this panics instead of returning a degenerate scalar.
616669 Self :: Sinusoidal => panic ! (
@@ -749,6 +802,7 @@ impl FeatureTransform {
749802 "yeo_johnson" => Ok ( Self :: YeoJohnson ) ,
750803 "sinusoidal" => Ok ( Self :: Sinusoidal ) ,
751804 "soft_sign" => Ok ( Self :: SoftSign ) ,
805+ "soft_clip" => Ok ( Self :: SoftClip ) ,
752806 _ => Err ( PredictError :: UnknownFeatureTransform ) ,
753807 }
754808 }
@@ -773,6 +827,7 @@ impl FeatureTransform {
773827 Self :: YeoJohnson => "yeo_johnson" ,
774828 Self :: Sinusoidal => "sinusoidal" ,
775829 Self :: SoftSign => "soft_sign" ,
830+ Self :: SoftClip => "soft_clip" ,
776831 }
777832 }
778833}
@@ -1461,7 +1516,7 @@ mod tests {
14611516 /// Returns every FeatureTransform variant currently in the enum.
14621517 /// Keep this in sync with the enum when adding new variants —
14631518 /// the universal tests below iterate this list.
1464- fn all_variants ( ) -> [ FeatureTransform ; 16 ] {
1519+ fn all_variants ( ) -> [ FeatureTransform ; 17 ] {
14651520 [
14661521 FeatureTransform :: Identity ,
14671522 FeatureTransform :: Log ,
@@ -1479,6 +1534,7 @@ mod tests {
14791534 FeatureTransform :: ClipThenLog1pThenWinsor ,
14801535 FeatureTransform :: YeoJohnson ,
14811536 FeatureTransform :: SoftSign ,
1537+ FeatureTransform :: SoftClip ,
14821538 ]
14831539 }
14841540
@@ -1506,6 +1562,10 @@ mod tests {
15061562 // fallback (scale = 1) is exercised by all_variants(), so a
15071563 // non-trivial scale here checks apply_with_params too.
15081564 FeatureTransform :: SoftSign => & [ 2.0_f32 ] ,
1565+ // SoftClip takes optional [knee, soft]; non-trivial values here
1566+ // check apply_with_params (the no-param [1,1] path is in
1567+ // all_variants()).
1568+ FeatureTransform :: SoftClip => & [ 2.0_f32 , 1.5 ] ,
15091569 // Sinusoidal is an expander (panics under scalar apply),
15101570 // so it is excluded from `all_variants()` and never reaches
15111571 // these scalar NaN-safety helpers. This arm only keeps the
@@ -1693,6 +1753,69 @@ mod tests {
16931753 assert ! ( ( s0 - s1) . abs( ) < 1e-6 ) ;
16941754 }
16951755
1756+ // -----------------------------------------------------------------
1757+ // SoftClip (smooth Huber-log winsor — identity core + unbounded tail)
1758+ // -----------------------------------------------------------------
1759+
1760+ #[ test]
1761+ fn soft_clip_token_round_trip_and_flags ( ) {
1762+ let t = FeatureTransform :: from_token ( "soft_clip" ) . expect ( "parse" ) ;
1763+ assert_eq ! ( t, FeatureTransform :: SoftClip ) ;
1764+ assert_eq ! ( t. as_token( ) , "soft_clip" ) ;
1765+ assert ! ( !t. requires_params( ) , "knee/soft are optional (default 1,1)" ) ;
1766+ assert ! ( !t. is_expander( ) ) ;
1767+ assert_eq ! ( t. output_arity( & [ ] ) , 1 ) ;
1768+ assert_eq ! ( t. output_arity( & [ 2.0 , 1.5 ] ) , 1 ) ;
1769+ }
1770+
1771+ #[ test]
1772+ fn soft_clip_is_identity_in_core_odd_monotone_unbounded_and_C1 ( ) {
1773+ // knee=2, soft=1
1774+ let f = |x : f32 | FeatureTransform :: SoftClip . apply_with_params ( x, & [ 2.0 , 1.0 ] ) ;
1775+ // Identity inside the core |x| ≤ 2.
1776+ for & x in & [ -2.0_f32 , -1.3 , -0.1 , 0.0 , 0.1 , 1.3 , 2.0 ] {
1777+ assert ! ( ( f( x) - x) . abs( ) < 1e-6 , "core not identity at {x}: {}" , f( x) ) ;
1778+ }
1779+ // Odd.
1780+ for & x in & [ 0.5_f32 , 2.0 , 5.0 , 100.0 ] {
1781+ assert ! ( ( f( -x) + f( x) ) . abs( ) < 1e-5 , "not odd at {x}" ) ;
1782+ }
1783+ // Just past the knee: f(2+δ) = 2 + ln(1+δ). Reference formula.
1784+ for & a in & [ 2.5_f32 , 4.0 , 10.0 , 1000.0 ] {
1785+ let want = 2.0 + ( a - 2.0 ) . ln_1p ( ) ;
1786+ assert ! ( ( f( a) - want) . abs( ) < 1e-4 , "tail wrong at {a}: {} vs {want}" , f( a) ) ;
1787+ }
1788+ // UNBOUNDED (unlike soft_sign): grows without a ceiling — this is what
1789+ // keeps the corruption-tail ORDER. f(1e6) ≫ f(100) ≫ knee.
1790+ assert ! ( f( 1e6 ) > f( 100.0 ) && f( 100.0 ) > f( 10.0 ) && f( 10.0 ) > 2.0 ) ;
1791+ assert ! ( f( 1e6 ) > 10.0 , "log tail still climbs at extreme x" ) ;
1792+ // Strictly monotone across the knee (no flat, no step).
1793+ let mut prev = f ( -1000.0 ) ;
1794+ for i in -2000 ..=2000 {
1795+ let cur = f ( i as f32 * 0.02 ) ;
1796+ assert ! ( cur > prev, "not strictly monotone near {}" , i as f32 * 0.02 ) ;
1797+ prev = cur;
1798+ }
1799+ // C¹ at the knee: numerical slope just inside ≈ just outside ≈ 1.
1800+ let eps = 1e-3_f32 ;
1801+ let din = ( f ( 2.0 ) - f ( 2.0 - eps) ) / eps;
1802+ let dout = ( f ( 2.0 + eps) - f ( 2.0 ) ) / eps;
1803+ assert ! ( ( din - 1.0 ) . abs( ) < 1e-2 && ( dout - 1.0 ) . abs( ) < 1e-2 ,
1804+ "slope discontinuity at knee: in={din} out={dout}" ) ;
1805+ }
1806+
1807+ #[ test]
1808+ fn soft_clip_param_fallbacks_guard_garbage_screen_entries ( ) {
1809+ // No params → knee=1, soft=1: identity up to 1, log tail after.
1810+ let d = FeatureTransform :: SoftClip . apply ( 0.5 ) ;
1811+ assert ! ( ( d - 0.5 ) . abs( ) < 1e-6 ) ;
1812+ let d2 = FeatureTransform :: SoftClip . apply ( 3.0 ) ; // 1 + ln(1+2)
1813+ assert ! ( ( d2 - ( 1.0 + 2.0_f32 . ln_1p( ) ) ) . abs( ) < 1e-5 ) ;
1814+ // Non-positive knee/soft fall back to 1.
1815+ let g = FeatureTransform :: SoftClip . apply_with_params ( 3.0 , & [ 0.0 , -5.0 ] ) ;
1816+ assert ! ( ( g - d2) . abs( ) < 1e-5 , "knee≤0/soft≤0 should fall back to 1,1" ) ;
1817+ }
1818+
16961819 // -----------------------------------------------------------------
16971820 // Sinusoidal embedding (expander variant)
16981821 // -----------------------------------------------------------------
0 commit comments