Skip to content

Commit e7061c8

Browse files
authored
BugFix/SDFCOMFUND-682 Retro vote quality improved logistic func parameters (#68)
1 parent 2b805a8 commit e7061c8

1 file changed

Lines changed: 64 additions & 39 deletions

File tree

neurons/src/retro_vote_quality.rs

Lines changed: 64 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ use std::collections::HashMap;
55

66
const DELEGATED_VOTE_DENOMINATOR: i32 = 2;
77
const 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)]
109
pub 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+
113105
fn 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

Comments
 (0)