@@ -5,7 +5,6 @@ use std::collections::HashMap;
55
66const DELEGATED_VOTE_DENOMINATOR : i32 = 2 ;
77const FIXED_POINT_SCALING_FACTOR : i32 = 100 ; // *10 to mitigate float precission loss, and *10 to allow integer division
8- const ZERO_SNAP_THRESHOLD : f64 = 0.001 ; // float-noise floor: |output| <= this collapses to 0
98#[ derive( Clone , Debug ) ]
109pub struct RetroVoteQualityNeuron {
1110 votes_per_round : HashMap < u32 , HashMap < String , HashMap < String , Vote > > > , // round -> submission -> user -> vote (Yes/No/Abstain/Delegate)
@@ -66,12 +65,7 @@ impl RetroVoteQualityNeuron {
6665 }
6766 }
6867 let raw_bonus = total_bonus as f64 / FIXED_POINT_SCALING_FACTOR as f64 ;
69- // Mirror the curve around 0: run |raw| through the logistic (baseline-shifted
70- // so raw=0 maps to 0) and flip the sign for negative raw bonuses, so penalties
71- // produce symmetric negative scores instead of being clipped at the a=0 floor.
72- let magnitude = logistic ( raw_bonus. abs ( ) ) - logistic ( 0.0 ) ;
73- let signed = if raw_bonus < 0.0 { -magnitude } else { magnitude } ;
74- if signed. abs ( ) <= ZERO_SNAP_THRESHOLD { 0.0 } else { signed }
68+ generalised_logistic_function ( -5.0 , 5.0 , 1.0 , 1.0 , 0.4 , 1.0 , 0.0 , raw_bonus)
7569 }
7670 fn resolve_delegated_vote (
7771 & self ,
@@ -107,9 +101,7 @@ impl RetroVoteQualityNeuron {
107101 None
108102 }
109103}
110- fn logistic ( raw_bonus : f64 ) -> f64 {
111- generalised_logistic_function ( 0.0 , 5.0 , 1.0 , 4.0 , 1.0 , 1.0 , 1.0 , raw_bonus)
112- }
104+
113105fn tranche_status_to_bonus ( tranche_status : & str ) -> i32 {
114106 match tranche_status {
115107 "Live on Stellar within 6 months" => 30 , // 0.3
@@ -149,12 +141,11 @@ mod tests {
149141
150142 const FLOAT_EPS : f64 = 1e-12 ;
151143
144+ // Mirrors the production curve: the generalised logistic with these parameters
145+ // reduces to 5*tanh(0.2*raw) — antisymmetric about the origin, bounded in (-5, 5),
146+ // with raw=0 mapping to exactly 0.0.
152147 fn logistic_of ( raw_bonus : f64 ) -> f64 {
153- // Mirrors the production formula: logistic(|raw|) - logistic(0), negated
154- // for negative raw, so raw=0 maps to 0 and penalties stay symmetric.
155- let f = |x| generalised_logistic_function ( 0.0 , 5.0 , 1.0 , 4.0 , 1.0 , 1.0 , 1.0 , x) ;
156- let magnitude = f ( raw_bonus. abs ( ) ) - f ( 0.0 ) ;
157- if raw_bonus < 0.0 { -magnitude } else { magnitude }
148+ generalised_logistic_function ( -5.0 , 5.0 , 1.0 , 1.0 , 0.4 , 1.0 , 0.0 , raw_bonus)
158149 }
159150
160151 fn assert_close ( actual : f64 , expected : f64 ) {
@@ -227,7 +218,7 @@ mod tests {
227218 HashMap :: new ( ) ,
228219 & [ ( "sub1" , "rec1" , LIVE_WITHIN_6 ) ] ,
229220 ) ;
230- let baseline = logistic_of ( 0.0 ) ;
221+ let baseline = 0.0 ;
231222 assert_close ( neuron. run_user ( "alice" ) , baseline) ;
232223 assert_close ( neuron. run_user ( "bob" ) , baseline) ;
233224 }
@@ -266,7 +257,7 @@ mod tests {
266257 votes ( 30 , "sub1" , & [ ( "alice" , Vote :: Abstain ) ] ) ,
267258 & [ ( "sub1" , "rec1" , LIVE_WITHIN_6 ) ] ,
268259 ) ;
269- let baseline = logistic_of ( 0.0 ) ;
260+ let baseline = 0.0 ;
270261 assert_close ( neuron_no. run_user ( "alice" ) , baseline) ;
271262 assert_close ( neuron_abstain. run_user ( "alice" ) , baseline) ;
272263 }
@@ -291,7 +282,7 @@ mod tests {
291282 votes ( 30 , "sub1" , & [ ( "bob" , Vote :: Yes ) ] ) ,
292283 & [ ( "sub1" , "rec1" , LIVE_WITHIN_6 ) ] ,
293284 ) ;
294- let baseline = logistic_of ( 0.0 ) ;
285+ let baseline = 0.0 ;
295286 assert_close ( neuron_missing_round. run_user ( "alice" ) , baseline) ;
296287 assert_close ( neuron_missing_submission. run_user ( "alice" ) , baseline) ;
297288 assert_close ( neuron_missing_user. run_user ( "alice" ) , baseline) ;
@@ -308,7 +299,7 @@ mod tests {
308299 HashMap :: new ( ) , // empty tranche_status_map
309300 submissions_airtable_ids,
310301 ) ;
311- assert_close ( neuron. run_user ( "alice" ) , logistic_of ( 0.0 ) ) ;
302+ assert_close ( neuron. run_user ( "alice" ) , 0.0 ) ;
312303 }
313304
314305 #[ test]
@@ -320,7 +311,7 @@ mod tests {
320311 HashMap :: from ( [ ( LIVE_WITHIN_6 . to_string ( ) , vec ! [ "rec1" . to_string( ) ] ) ] ) ,
321312 HashMap :: new ( ) , // sub1 has no airtable_id mapping
322313 ) ;
323- assert_close ( neuron. run_user ( "alice" ) , logistic_of ( 0.0 ) ) ;
314+ assert_close ( neuron. run_user ( "alice" ) , 0.0 ) ;
324315 }
325316
326317 #[ test]
@@ -341,7 +332,7 @@ mod tests {
341332 & [ ( "sub1" , "rec1" , LIVE_WITHIN_6 ) ] ,
342333 ) ;
343334 let yes_bonus = logistic_of ( 0.30 ) ;
344- let baseline = logistic_of ( 0.0 ) ;
335+ let baseline = 0.0 ;
345336 assert_close ( neuron. run_user ( "alice" ) , yes_bonus) ;
346337 assert_close ( neuron. run_user ( "bob" ) , yes_bonus) ;
347338 assert_close ( neuron. run_user ( "carol" ) , baseline) ;
@@ -383,25 +374,57 @@ mod tests {
383374 }
384375
385376 #[ test]
386- fn empty_data_returns_logistic_of_zero ( ) {
377+ fn zero_bonus_produces_exactly_zero ( ) {
378+ // No contributions => raw bonus 0. The curve is centred on the origin, so an
379+ // inactive voter scores exactly 0.0 — no positive baseline offset, no float noise.
387380 let neuron = build_neuron ( HashMap :: new ( ) , HashMap :: new ( ) , & [ ] ) ;
388- assert_close ( neuron. run_user ( "alice" ) , logistic_of ( 0.0 ) ) ;
381+ assert_eq ! ( neuron. run_user( "alice" ) , 0.0 ) ;
389382 }
390383
391384 #[ test]
392385 fn logistic_parameters_pinned ( ) {
393- // Locks in the (a=0, k=5, c=1, q=4, b=1, nu=1, x_off=1) configuration so
394- // accidental parameter changes are caught even if `logistic_of` is updated.
395- // Raw logistic(0) = 5 / (1 + 4 * exp(1)) ≈ 0.421119042004487; production
396- // subtracts that baseline so a voter with no contributions scores 0.
397- let neuron = build_neuron ( HashMap :: new ( ) , HashMap :: new ( ) , & [ ] ) ;
398- let result = neuron. run_user ( "alice" ) ;
399- assert ! ( result. abs( ) < 1e-12 , "expected 0 for empty contributions, got {result}" ) ;
400- let raw_baseline = generalised_logistic_function ( 0.0 , 5.0 , 1.0 , 4.0 , 1.0 , 1.0 , 1.0 , 0.0 ) ;
401- assert ! (
402- ( raw_baseline - 0.421_119_042_004_487 ) . abs( ) < 1e-12 ,
403- "logistic baseline drifted: got {raw_baseline}"
404- ) ;
386+ // Locks in the (a=-5, k=5, c=1, q=1, b=0.4, nu=1, x_off=0) configuration.
387+ // With these parameters the generalised logistic reduces exactly to the
388+ // antisymmetric curve 5*tanh(0.2*raw). `tanh()` from std is an independent
389+ // oracle, so any drift in run_user's parameters is caught even though
390+ // `logistic_of` mirrors production. The neuron is driven through the real
391+ // run_user path (one Yes vote => raw == the tranche bonus).
392+ let scored = |status : & str | {
393+ build_neuron (
394+ votes ( 30 , "sub1" , & [ ( "alice" , Vote :: Yes ) ] ) ,
395+ HashMap :: new ( ) ,
396+ & [ ( "sub1" , "rec1" , status) ] ,
397+ )
398+ . run_user ( "alice" )
399+ } ;
400+ // +0.30 reward and -0.30 penalty pinned against the closed form.
401+ assert_close ( scored ( LIVE_WITHIN_6 ) , 5.0 * ( 0.2 * 0.30_f64 ) . tanh ( ) ) ;
402+ assert_close ( scored ( NOT_LIVE_AWARDED ) , 5.0 * ( 0.2 * -0.30_f64 ) . tanh ( ) ) ;
403+ // Empty contributions => raw 0 => exactly 0.0, no baseline offset.
404+ let empty = build_neuron ( HashMap :: new ( ) , HashMap :: new ( ) , & [ ] ) ;
405+ assert_eq ! ( empty. run_user( "alice" ) , 0.0 ) ;
406+ }
407+
408+ #[ test]
409+ fn positive_and_negative_bonuses_are_symmetric ( ) {
410+ // Equal magnitude, opposite sign: LIVE_WITHIN_6 = +0.30, NOT_LIVE_AWARDED = -0.30.
411+ // A reward and an equal-sized penalty must be exact negatives of each other, so
412+ // the neuron treats good and bad voting symmetrically.
413+ let reward = build_neuron (
414+ votes ( 30 , "sub1" , & [ ( "alice" , Vote :: Yes ) ] ) ,
415+ HashMap :: new ( ) ,
416+ & [ ( "sub1" , "rec1" , LIVE_WITHIN_6 ) ] ,
417+ )
418+ . run_user ( "alice" ) ;
419+ let penalty = build_neuron (
420+ votes ( 30 , "sub1" , & [ ( "alice" , Vote :: Yes ) ] ) ,
421+ HashMap :: new ( ) ,
422+ & [ ( "sub1" , "rec1" , NOT_LIVE_AWARDED ) ] ,
423+ )
424+ . run_user ( "alice" ) ;
425+ assert ! ( reward > 0.0 , "reward should be positive, got {reward}" ) ;
426+ assert ! ( penalty < 0.0 , "penalty should be negative, got {penalty}" ) ;
427+ assert_close ( penalty, -reward) ;
405428 }
406429
407430 #[ test]
@@ -429,13 +452,15 @@ mod tests {
429452 let huge = mk ( 500 , LIVE_WITHIN_6 ) ; // raw is large enough that f64 saturates at 5.0
430453 assert ! ( one < many, "one={one} many={many}" ) ;
431454 assert ! ( many <= huge, "many={many} huge={huge}" ) ;
432- // Bounded above by k=5 minus the baseline subtraction (~0.421) .
455+ // Bounded above by the upper asymptote k=5 .
433456 assert ! ( huge <= 5.0 , "huge={huge}" ) ;
434- assert ! ( many > 4.0 , "many={many}" ) ; // logistic_of( 15) is already very close to k=5
435- // Penalties mirror the positive side: bounded below by ~-(k - baseline) .
457+ assert ! ( many > 4.0 , "many={many}" ) ; // 5*tanh(0.2* 15) is already very close to k=5
458+ // Penalties mirror the positive side: bounded below by the lower asymptote -k=-5 .
436459 let very_negative = mk ( 50 , NOT_LIVE_AWARDED ) ; // raw = 50 * -0.30 = -15
437460 assert ! ( very_negative >= -5.0 , "very_negative={very_negative}" ) ;
438461 assert ! ( very_negative < -4.0 , "very_negative={very_negative}" ) ;
462+ // Equal-magnitude reward and penalty are exact negatives of each other.
463+ assert_close ( very_negative, -many) ;
439464 }
440465
441466 #[ test]
@@ -448,7 +473,7 @@ mod tests {
448473 let users = vec ! [ "alice" . to_string( ) , "bob" . to_string( ) , "carol" . to_string( ) ] ;
449474 let result = neuron. calculate_result ( & users) ;
450475 assert_eq ! ( result. len( ) , 3 ) ;
451- let baseline = logistic_of ( 0.0 ) ;
476+ let baseline = 0.0 ;
452477 assert_close ( * result. get ( "alice" ) . unwrap ( ) , logistic_of ( 0.30 ) ) ;
453478 assert_close ( * result. get ( "bob" ) . unwrap ( ) , baseline) ;
454479 assert_close ( * result. get ( "carol" ) . unwrap ( ) , baseline) ;
0 commit comments