1use chrono::Utc;
12
13use crate::reputation::ReputationImport;
14use serde::{Deserialize, Serialize, Serializer};
15#[cfg(feature = "sqlx")]
16use sqlx::FromRow;
17
18const SECONDS_PER_DAY: u64 = 86_400;
20
21pub fn day_truncate(timestamp: i64) -> u64 {
31 truncate_since(timestamp.max(0) as u64)
32}
33
34pub(crate) fn truncate_since(since: u64) -> u64 {
37 since - since % SECONDS_PER_DAY
38}
39
40pub(crate) fn serialize_since<S: Serializer>(
43 since: &Option<u64>,
44 serializer: S,
45) -> Result<S::Ok, S::Error> {
46 since.map(truncate_since).serialize(serializer)
47}
48
49#[derive(Debug, Default, Deserialize, Serialize, Clone)]
55
56pub struct UserInfo {
57 pub rating: f64,
59 pub reviews: i64,
61 pub operating_days: u64,
67 #[serde(
75 default,
76 skip_serializing_if = "Option::is_none",
77 serialize_with = "serialize_since"
78 )]
79 pub since: Option<u64>,
80}
81
82#[cfg_attr(feature = "sqlx", derive(FromRow))]
88#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
89pub struct User {
90 pub pubkey: String,
92 pub is_admin: i64,
95 pub admin_password: Option<String>,
97 pub is_solver: i64,
99 pub is_banned: i64,
101 pub category: i64,
103 pub last_trade_index: i64,
107 pub total_reviews: i64,
109 pub total_rating: f64,
111 pub last_rating: i64,
113 pub max_rating: i64,
115 pub min_rating: i64,
117 pub created_at: i64,
121 #[cfg_attr(feature = "sqlx", sqlx(default))]
124 #[serde(default)]
125 pub seeded_reviews: i64,
126 #[cfg_attr(feature = "sqlx", sqlx(default))]
129 #[serde(default)]
130 pub seeded_rating_sum: f64,
131 #[cfg_attr(feature = "sqlx", sqlx(default))]
140 #[serde(default)]
141 pub native_rating_sum: f64,
142 #[cfg_attr(feature = "sqlx", sqlx(default))]
150 #[serde(default)]
151 pub native_created_at: Option<i64>,
152 #[cfg_attr(feature = "sqlx", sqlx(default))]
155 #[serde(default)]
156 pub reputation_exported_to: Option<String>,
157 #[cfg_attr(feature = "sqlx", sqlx(default))]
160 #[serde(default)]
161 pub reputation_exported_at: Option<i64>,
162}
163
164impl User {
165 pub fn new(
170 pubkey: String,
171 is_admin: i64,
172 is_solver: i64,
173 is_banned: i64,
174 category: i64,
175 trade_index: i64,
176 ) -> Self {
177 let created_at = Utc::now().timestamp();
178 Self {
179 pubkey,
180 is_admin,
181 admin_password: None,
182 is_solver,
183 is_banned,
184 category,
185 last_trade_index: trade_index,
186 total_reviews: 0,
187 total_rating: 0.0,
188 last_rating: 0,
189 max_rating: 0,
190 min_rating: 0,
191 created_at,
192 native_created_at: Some(created_at),
193 ..Self::default()
194 }
195 }
196
197 pub fn apply_reputation_import(&mut self, import: &ReputationImport) {
210 let reviews = i64::from(import.reviews);
211 let rating = import.rating();
212 let rounded = rating.round() as i64;
213 self.native_created_at = Some(self.native_created_at());
214 self.total_rating = (self.total_rating * self.total_reviews as f64
215 + rating * reviews as f64)
216 / (self.total_reviews + reviews) as f64;
217 self.total_reviews += reviews;
218 self.created_at = self.created_at.min(import.since as i64);
219 for extreme in [
220 &mut self.min_rating,
221 &mut self.max_rating,
222 &mut self.last_rating,
223 ] {
224 if *extreme == 0 {
225 *extreme = rounded;
226 }
227 }
228 self.seeded_reviews += reviews;
229 self.seeded_rating_sum += rating * reviews as f64;
230 }
231
232 pub fn revert_reputation_import(
243 &mut self,
244 import: &ReputationImport,
245 remaining_since: Option<i64>,
246 ) {
247 let reviews = i64::from(import.reviews);
248 let rating = import.rating();
249 let remaining = (self.total_reviews - reviews).max(0);
250 self.total_rating = if remaining == 0 {
251 0.0
252 } else {
253 (self.total_rating * self.total_reviews as f64 - rating * reviews as f64)
254 / remaining as f64
255 };
256 self.total_reviews = remaining;
257 self.seeded_reviews = (self.seeded_reviews - reviews).max(0);
258 self.seeded_rating_sum -= rating * reviews as f64;
259 let native = self.native_created_at();
260 self.created_at = remaining_since.map_or(native, |since| since.min(native));
261 if remaining == 0 {
262 self.min_rating = 0;
263 self.max_rating = 0;
264 self.last_rating = 0;
265 }
266 }
267
268 pub fn native_stats(&self) -> (i64, f64) {
276 let reviews = (self.total_reviews - self.seeded_reviews).max(0);
277 let rating = if reviews == 0 {
278 0.0
279 } else {
280 self.native_rating_sum / reviews as f64
281 };
282 (reviews, rating)
283 }
284
285 pub fn native_created_at(&self) -> i64 {
290 self.native_created_at.unwrap_or(self.created_at)
291 }
292
293 pub fn update_rating(&mut self, rating: u8) {
315 self.total_reviews += 1;
318 let old_rating = self.total_rating;
319 if self.total_reviews <= 1 {
321 let first_rating = rating as f64;
323 self.total_rating = first_rating / 2.0;
324 self.max_rating = rating.into();
325 self.min_rating = rating.into();
326 } else {
327 self.total_rating =
328 old_rating + ((rating as f64) - old_rating) / (self.total_reviews as f64);
329 if self.max_rating < rating.into() {
330 self.max_rating = rating.into();
331 }
332 if self.min_rating > rating.into() {
333 self.min_rating = rating.into();
334 }
335 }
336 self.last_rating = rating.into();
338 self.native_rating_sum += f64::from(rating);
340 }
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346
347 mod import {
348 use super::*;
349 use serde_json::Value;
350
351 const VECTORS: &str = include_str!("../tests/vectors/reputation_v1.json");
352
353 fn import(reviews: u32, rating_hundredths: u16, since: u64) -> ReputationImport {
354 ReputationImport {
355 reviews,
356 rating_hundredths,
357 since,
358 }
359 }
360
361 fn row(v: &Value) -> User {
362 User {
363 total_reviews: v["total_reviews"].as_i64().unwrap(),
364 total_rating: v["total_rating"].as_f64().unwrap(),
365 created_at: v["created_at"].as_i64().unwrap(),
366 native_created_at: Some(v["native_created_at"].as_i64().unwrap()),
367 min_rating: v["min_rating"].as_i64().unwrap(),
368 max_rating: v["max_rating"].as_i64().unwrap(),
369 last_rating: v["last_rating"].as_i64().unwrap(),
370 seeded_reviews: v["seeded_reviews"].as_i64().unwrap(),
371 seeded_rating_sum: v["seeded_rating_sum"].as_f64().unwrap(),
372 native_rating_sum: v["native_rating_sum"].as_f64().unwrap(),
373 ..User::default()
374 }
375 }
376
377 fn assert_rows_match(actual: &User, expected: &User, tolerance: f64, what: &str) {
378 let close = |a: f64, b: f64| (a - b).abs() <= tolerance;
379 assert!(
380 close(actual.total_rating, expected.total_rating),
381 "{what}: total_rating {} != {}",
382 actual.total_rating,
383 expected.total_rating
384 );
385 assert!(
386 close(actual.seeded_rating_sum, expected.seeded_rating_sum),
387 "{what}: seeded_rating_sum"
388 );
389 assert!(
390 close(actual.native_rating_sum, expected.native_rating_sum),
391 "{what}: native_rating_sum"
392 );
393 let exact = |u: &User| {
394 (
395 u.total_reviews,
396 u.created_at,
397 u.native_created_at,
398 u.min_rating,
399 u.max_rating,
400 u.last_rating,
401 u.seeded_reviews,
402 )
403 };
404 assert_eq!(exact(actual), exact(expected), "{what}");
405 }
406
407 #[test]
408 fn the_merge_vectors_apply_and_reverse() {
409 let v: Value = serde_json::from_str(VECTORS).unwrap();
410 let tolerance = v["merge"]["tolerance"].as_f64().unwrap();
411 for case in v["merge"]["cases"].as_array().unwrap() {
412 let name = case["name"].as_str().unwrap();
413 let figures = &case["import"];
414 let rating = crate::reputation::rating_hundredths(
415 figures["rating"].as_str().unwrap().parse().unwrap(),
416 )
417 .unwrap();
418 let imported = import(
419 figures["reviews"].as_u64().unwrap() as u32,
420 rating,
421 figures["since"].as_u64().unwrap(),
422 );
423 let mut user = row(&case["before"]);
424 user.apply_reputation_import(&imported);
425 assert_rows_match(&user, &row(&case["after"]), tolerance, name);
426 user.revert_reputation_import(&imported, None);
427 assert_rows_match(&user, &row(&case["reverted"]), tolerance, name);
428 }
429 }
430
431 #[test]
432 fn imports_from_several_issuers_add_up_and_keep_the_earliest_date() {
433 let mut user = User {
434 created_at: 1_790_000_000,
435 ..User::new("p".into(), 0, 0, 0, 0, 0)
436 };
437 user.native_created_at = Some(user.created_at);
438 user.apply_reputation_import(&import(10, 400, 1_700_006_400));
439 user.apply_reputation_import(&import(30, 500, 1_690_070_400));
440 assert_eq!(user.total_reviews, 40);
441 assert!((user.total_rating - 4.75).abs() < 1e-12);
442 assert_eq!(user.created_at, 1_690_070_400);
443 assert_eq!(user.seeded_reviews, 40);
444 assert_eq!(user.native_stats(), (0, 0.0));
445
446 user.revert_reputation_import(&import(30, 500, 1_690_070_400), Some(1_700_006_400));
448 assert_eq!(user.total_reviews, 10);
449 assert!((user.total_rating - 4.0).abs() < 1e-12);
450 assert_eq!(user.created_at, 1_700_006_400);
451 }
452
453 #[test]
454 fn native_ratings_before_and_after_an_import_stay_exportable_as_native() {
455 let mut user = User::new("p".into(), 0, 0, 0, 0, 0);
456 user.update_rating(5);
457 user.update_rating(3);
458 user.apply_reputation_import(&import(100, 487, 1_696_204_800));
459 user.update_rating(1);
460 assert_eq!(user.native_stats(), (3, 3.0));
461 assert_eq!(user.total_reviews, 103);
462 assert_eq!(user.min_rating, 1);
463
464 let before_revert = user.total_rating * 103.0 - 487.0;
466 user.revert_reputation_import(&import(100, 487, 1_696_204_800), None);
467 assert_eq!(user.total_reviews, 3);
468 assert!((user.total_rating * 3.0 - before_revert).abs() < 1e-9);
469 assert_eq!(user.native_stats(), (3, 3.0));
470 }
471
472 #[test]
473 fn a_legacy_record_without_a_native_date_gets_it_pinned_before_the_import_moves_it() {
474 let mut user = User {
475 created_at: 1_780_000_000,
476 native_created_at: None,
477 ..User::default()
478 };
479 user.apply_reputation_import(&import(5, 450, 1_696_204_800));
480 assert_eq!(user.native_created_at, Some(1_780_000_000));
481 user.revert_reputation_import(&import(5, 450, 1_696_204_800), None);
482 assert_eq!(user.created_at, 1_780_000_000);
483 }
484
485 struct Lcg(u64);
488
489 impl Lcg {
490 fn next(&mut self, bound: u64) -> u64 {
491 self.0 = self
492 .0
493 .wrapping_mul(6_364_136_223_846_793_005)
494 .wrapping_add(1_442_695_040_888_963_407);
495 (self.0 >> 33) % bound
496 }
497
498 fn import(&mut self) -> ReputationImport {
499 import(
500 5 + self.next(500) as u32,
501 100 + self.next(401) as u16,
502 1_577_836_800 + self.next(2_000) * 86_400,
503 )
504 }
505
506 fn user(&mut self) -> User {
507 let mut user = User::new("p".into(), 0, 0, 0, 0, 0);
508 user.created_at = 1_700_000_000 + self.next(100_000_000) as i64;
509 user.native_created_at = Some(user.created_at);
510 for _ in 0..self.next(20) {
511 user.update_rating(1 + self.next(5) as u8);
512 }
513 user
514 }
515 }
516
517 #[test]
518 fn an_import_never_lowers_reviews_nor_moves_dates_forward_nor_touches_native_state() {
519 let mut rng = Lcg(7);
520 for _ in 0..2_000 {
521 let mut user = rng.user();
522 let before = user.clone();
523 user.apply_reputation_import(&rng.import());
524 assert!(user.total_reviews > before.total_reviews);
525 assert!(user.created_at <= before.created_at);
526 assert_eq!(user.native_created_at, before.native_created_at);
527 assert_eq!(user.native_rating_sum, before.native_rating_sum);
528 assert_eq!(user.native_stats().0, before.native_stats().0);
529 assert!((1.0..=5.0).contains(&user.total_rating));
530 }
531 }
532
533 #[test]
534 fn apply_then_revert_restores_the_row() {
535 let mut rng = Lcg(11);
536 for _ in 0..2_000 {
537 let mut user = rng.user();
538 let before = user.clone();
539 let imported = rng.import();
540 user.apply_reputation_import(&imported);
541 user.revert_reputation_import(&imported, None);
542 assert_rows_match(&user, &before, 1e-9, "apply then revert");
543 }
544 }
545
546 #[test]
547 fn a_chain_a_b_c_exports_from_b_what_b_earned_natively() {
548 let mut rng = Lcg(13);
549 for _ in 0..500 {
550 let mut on_b = rng.user();
551 let native_before = on_b.native_stats();
552 on_b.apply_reputation_import(&rng.import());
553 for _ in 0..rng.next(5) {
554 on_b.apply_reputation_import(&rng.import());
555 }
556 let (reviews, rating) = on_b.native_stats();
557 assert_eq!(reviews, native_before.0);
558 assert!((rating - native_before.1).abs() < 1e-12);
559 }
560 }
561 }
562
563 #[test]
564 fn a_first_native_rating_is_summed_raw_although_the_average_is_damped() {
565 let mut user = User::new("pubkey".into(), 0, 0, 0, 0, 0);
566 user.update_rating(5);
567 assert_eq!(user.total_rating, 2.5);
568 assert_eq!(user.native_rating_sum, 5.0);
569 }
570
571 #[test]
572 fn native_ratings_sum_to_their_plain_total() {
573 let mut user = User::new("pubkey".into(), 0, 0, 0, 0, 0);
574 for rating in [5, 3, 4, 1, 5] {
575 user.update_rating(rating);
576 }
577 assert_eq!(user.native_rating_sum, 18.0);
578 assert_eq!(user.native_rating_sum / user.total_reviews as f64, 3.6);
579 }
580
581 #[test]
582 fn a_new_user_records_its_creation_as_the_native_date() {
583 let user = User::new("pubkey".into(), 0, 0, 0, 0, 0);
584 assert_eq!(user.native_created_at, Some(user.created_at));
585 assert_eq!(user.native_created_at(), user.created_at);
586 assert_eq!((user.seeded_reviews, user.seeded_rating_sum), (0, 0.0));
587 assert_eq!(user.native_rating_sum, 0.0);
588 assert_eq!(user.reputation_exported_to, None);
589 assert_eq!(user.reputation_exported_at, None);
590 }
591
592 #[test]
593 fn a_record_without_a_native_date_falls_back_to_created_at_never_zero() {
594 let user = User {
595 created_at: 1_700_000_000,
596 native_created_at: None,
597 ..User::default()
598 };
599 assert_eq!(user.native_created_at(), 1_700_000_000);
600 }
601
602 #[test]
603 fn a_serialised_user_from_before_the_new_fields_still_deserialises() {
604 let json = r#"{"pubkey":"p","is_admin":0,"admin_password":null,"is_solver":0,
605 "is_banned":0,"category":0,"last_trade_index":3,"total_reviews":2,
606 "total_rating":4.5,"last_rating":5,"max_rating":5,"min_rating":4,
607 "created_at":1700000000}"#;
608 let user: User = serde_json::from_str(json).unwrap();
609 assert_eq!(user.native_created_at(), 1_700_000_000);
610 assert_eq!(user.seeded_reviews, 0);
611 }
612
613 #[cfg(feature = "sqlx")]
617 #[tokio::test]
618 async fn a_row_without_the_new_columns_loads_with_their_defaults() {
619 let pool = sqlx::sqlite::SqlitePoolOptions::new()
620 .max_connections(1)
621 .connect(":memory:")
622 .await
623 .unwrap();
624 sqlx::query(
625 "CREATE TABLE users (pubkey char(64) primary key, is_admin integer not null default 0, \
626 admin_password char(64), is_solver integer not null default 0, \
627 is_banned integer not null default 0, category integer not null default 0, \
628 last_trade_index integer not null default 0, total_reviews integer not null default 0, \
629 total_rating real not null default 0.0, last_rating integer not null default 0, \
630 max_rating integer not null default 0, min_rating integer not null default 0, \
631 created_at integer not null)",
632 )
633 .execute(&pool)
634 .await
635 .unwrap();
636 sqlx::query(
637 "INSERT INTO users (pubkey, total_reviews, total_rating, created_at) \
638 VALUES ('p', 2, 4.5, 1700000000)",
639 )
640 .execute(&pool)
641 .await
642 .unwrap();
643 let user: User = sqlx::query_as("SELECT * FROM users")
644 .fetch_one(&pool)
645 .await
646 .unwrap();
647 assert_eq!(user.native_created_at, None);
648 assert_eq!(user.native_created_at(), 1_700_000_000);
649 assert_eq!((user.seeded_reviews, user.native_rating_sum), (0, 0.0));
650 assert_eq!(user.reputation_exported_to, None);
651 }
652
653 const DAY_START: i64 = 1_767_225_600;
655 const LATE_IN_DAY: i64 = DAY_START + 86_399;
656
657 #[test]
658 fn day_truncate_lands_on_the_start_of_the_utc_day() {
659 assert_eq!(day_truncate(DAY_START), DAY_START as u64);
662 assert_eq!(day_truncate(LATE_IN_DAY), DAY_START as u64);
663 assert_eq!(
664 day_truncate(DAY_START + 86_400),
665 (DAY_START + 86_400) as u64
666 );
667 }
668
669 #[test]
670 fn day_truncate_clamps_a_pre_epoch_timestamp_instead_of_wrapping() {
671 assert_eq!(day_truncate(-1), 0);
674 assert_eq!(day_truncate(0), 0);
675 }
676
677 #[test]
678 fn user_info_omits_since_when_unset() {
679 let info = UserInfo {
681 rating: 4.5,
682 reviews: 10,
683 operating_days: 30,
684 since: None,
685 };
686
687 let json = serde_json::to_string(&info).expect("serializes");
689
690 assert!(!json.contains("since"), "{json}");
693 }
694
695 #[test]
696 fn user_info_round_trips_with_since() {
697 let info = UserInfo {
699 rating: 4.5,
700 reviews: 10,
701 operating_days: 30,
702 since: Some(DAY_START as u64),
703 };
704
705 let parsed: UserInfo =
707 serde_json::from_str(&serde_json::to_string(&info).expect("serializes"))
708 .expect("parses");
709
710 assert_eq!(parsed.since, Some(DAY_START as u64));
712 assert_eq!(parsed.operating_days, 30);
713 }
714
715 #[test]
716 fn user_info_truncates_since_on_serialization() {
717 let info = UserInfo {
719 rating: 4.5,
720 reviews: 10,
721 operating_days: 30,
722 since: Some(LATE_IN_DAY as u64),
723 };
724
725 let json = serde_json::to_string(&info).expect("serializes");
727
728 assert!(json.contains(&format!("\"since\":{DAY_START}")), "{json}");
730 }
731
732 #[test]
733 fn user_info_from_a_peer_without_since_parses() {
734 let json = r#"{"rating":4.5,"reviews":10,"operating_days":30}"#;
736
737 let parsed: UserInfo = serde_json::from_str(json).expect("parses");
739
740 assert_eq!(parsed.since, None);
742 assert_eq!(parsed.operating_days, 30);
743 }
744
745 #[test]
746 fn first_vote_is_weighted_by_half() {
747 let mut user = User::default();
748
749 user.update_rating(5);
750
751 assert_eq!(user.total_reviews, 1);
752 assert_eq!(user.total_rating, 2.5);
753 assert_eq!(user.last_rating, 5);
754 assert_eq!(user.max_rating, 5);
755 assert_eq!(user.min_rating, 5);
756 }
757
758 #[test]
759 fn second_vote_is_folded_into_the_average() {
760 let mut user = User::default();
761 user.update_rating(5);
762
763 user.update_rating(1);
764
765 assert_eq!(user.total_reviews, 2);
768 assert!((user.total_rating - 1.75).abs() < 1e-9);
769 assert_eq!(user.last_rating, 1);
770 }
771
772 #[test]
773 fn low_vote_lowers_a_high_average() {
774 let mut user = User::default();
775 for _ in 0..10 {
776 user.update_rating(5);
777 }
778 let farmed_average = user.total_rating;
779 assert!((farmed_average - 4.75).abs() < 1e-9);
780
781 user.update_rating(1);
782
783 let expected = 48.5 / 11.0;
785 assert!(
786 user.total_rating < farmed_average,
787 "a 1-star review must lower the average, got {} (was {})",
788 user.total_rating,
789 farmed_average
790 );
791 assert!((user.total_rating - expected).abs() < 1e-9);
792 assert_eq!(user.last_rating, 1);
793 assert_eq!(user.min_rating, 1);
794 assert_eq!(user.max_rating, 5);
795 }
796
797 #[test]
798 fn high_vote_raises_a_low_average() {
799 let mut user = User::default();
800 user.update_rating(1);
801 user.update_rating(1);
802 let low_average = user.total_rating;
803
804 user.update_rating(5);
805
806 assert!(
807 user.total_rating > low_average,
808 "a 5-star review must raise the average, got {} (was {})",
809 user.total_rating,
810 low_average
811 );
812 assert!((user.total_rating - 6.5 / 3.0).abs() < 1e-9);
814 }
815
816 #[test]
817 fn min_and_max_track_extremes() {
818 let mut user = User::default();
819 user.update_rating(3);
820 user.update_rating(5);
821 user.update_rating(1);
822
823 assert_eq!(user.max_rating, 5);
824 assert_eq!(user.min_rating, 1);
825 }
826}