fix: rating running average folded in the previous vote instead of the new one - #163
Conversation
User::update_rating computed the incremental running average with self.last_rating (the previous review) instead of the incoming rating argument, so the newest vote was never folded into total_rating until the next one arrived. This inverted the effect of a bad review whenever the previous rating was above the current average: a 1-star review on a farmed 4.75 average *raised* the displayed score to ~4.77 instead of dropping it to ~4.41, undermining the reputation system as a trust signal. Use the new rating in the incremental-average formula and add regression tests covering the first-vote 1/2 weighting, that a low vote lowers a high average (and vice versa), and min/max extremum tracking.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
Walkthrough
ChangesRating calculation
Estimated code review effort: 2 (Simple) | ~10 minutes Merge Risk: ⚪ Minimal · up to The change makes each new review affect the running rating as intended, with regression coverage and passing checks reported. No actionable merge-blocking risk remains after normal review and validation. Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 438e9c2df8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| self.total_rating = | ||
| old_rating + ((self.last_rating as f64) - old_rating) / (self.total_reviews as f64); | ||
| old_rating + ((rating as f64) - old_rating) / (self.total_reviews as f64); |
There was a problem hiding this comment.
Migrate persisted averages before applying the new recurrence
When an existing user has ratings written by an earlier release, total_rating is not yet an average containing all total_reviews: the old recurrence omitted the latest rating and folded in the first rating twice. Applying the incoming rating directly therefore permanently skips that pending historical vote. For example, a persisted user rated 5 then 1 has total_rating = 3.75; receiving 5 after this upgrade produces 4.1667 here, whereas the documented weighting gives 2.8333. Existing rows need to be migrated/rebuilt (or explicitly transitioned using historical rating data) before this recurrence is used.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good observation — the math checks out. The old recurrence telescopes to total_rating = (r1/2 + r1 + r2 + … + r(n-1))/n, so every persisted aggregate double-counts the first vote and omits the latest one, and your 3.75 → 4.1667 vs 2.8333 example is exactly right. A few clarifications on why this PR doesn't (and can't) address it here:
-
An exact migration is not possible with the persisted data. The per-user error is exactly
(r1 − last_rating)/n.last_ratingis stored on the row, butr1(the first vote) is not — the DB only keeps aggregates (total_rating,total_reviews,last_rating,min_rating,max_rating), not the vote history. There is nothing to rebuild from at this layer. -
The residual error is bounded and self-correcting. The absolute error in the implied sum is fixed (
r1 − last_rating, at most ±4), so the average is off by at most4/nat upgrade time and decays as1/mas new votes are folded in correctly. The new recurrence is contractive over the historical error; it does not amplify it. -
Scope: this crate has no storage.
mostro-coreis a library; the SQLite rows live inmostrod. Any transition logic belongs in a daemon migration, not here — and holding this fix back would keep corrupting new data while the old data is discussed.
A practical follow-up for mostrod would be a one-time migration that folds in the pending stored vote once:
UPDATE users
SET total_rating = total_rating + (last_rating - total_rating) / total_reviews
WHERE total_reviews > 1;That removes the omitted-latest-vote half of the error, leaving only the double-counted first vote, which is unrecoverable from the persisted aggregates. I'll open an issue on mostrod proposing it.
Summary
User::update_ratingcomputed the incremental running average usingself.last_rating(the previous review) instead of the incomingratingargument, andlast_ratingwas only assigned afterwards. As a result the newest vote was never folded intototal_ratinguntil the next vote arrived.This inverted the effect of a bad review whenever the previous rating was above the current average:
total_rating = 4.75,last_rating = 5.Since
total_ratingis the only aggregate surfaced on the orderbook and in thePeerpayload, this systematically inflated reputations and made genuine bad reviews ineffective (or counterproductive) as a trust signal.Fix
Use the new vote in the incremental-average formula:
The documented 1/2 weighting of the first vote is preserved. One-line change; no public API, field, or DB schema changes.
Tests
Added a
#[cfg(test)]module insrc/user.rs(written first, reproducing the bug before the fix):min_rating/max_ratingtrack extremescargo test(97 lib tests + doctests),cargo clippy --all-targets --all-features,cargo fmt --checkall passFollow-up (out of scope)
update_rating(rating: u8)does not enforce the documentedMIN_RATING..=MAX_RATINGrange at this layer; callers validate it upstream (message.rs). Worth a separate issue to also validate at this boundary.Summary by CodeRabbit
Bug Fixes
Tests