Repository navigation
Conversation
…ute paths The Bayesian/composite/trending math lived twice: once in recompute-all-skills.ts and again in recompute-skill-scores.ts. Both had to agree on every future scoring change, and neither could be tested without a D1 database. Move the math into buildScoreUpdate() in recompute-score-update.ts: a pure function from the skill row, its aggregates and the global stats to the exact columns both callers write. Both callers now build their aggregates and call it, so the numbers stay identical while the duplicated ~60 lines per file disappear. The Math.max(..., 1) guards are preserved, so a zero-max trending window and null rating columns still produce finite numbers instead of NaN. Adds recompute-score-update.test.ts (8 cases) covering the guard paths, Bayesian shrinkage toward the global mean and the neutral 0.5 success rate. Refs #27
clark-cant
left a comment
There was a problem hiding this comment.
Re-review — 370a7a8 (2026-10-31)
Read all four files at the head plus leaderboard-scoring.ts and scoring-utils.ts, and checked every claim in the description.
Verified
- LOC is exactly as advertised:
recompute-all-skills.ts166,recompute-skill-scores.ts117,recompute-score-update.ts113, test 157. The 210 -> under-200 target for #27 is met, and it leaves room for the 15-20 line guard #26 will add. - The math now has one production call site.
grep -rn "computeBayesianRating|computeCompositeScore|computeTrendingScore" apps/webreturns onlyrecompute-score-update.tsand the definitions inleaderboard-scoring.ts; nothing else consumes the primitives directly, so the "both copies must agree" failure mode is gone rather than merely reduced. - Behaviour parity holds line by line: same
?? 0coercions, sameMath.max(maxNetVotes, 1)/Math.max(maxFavorites, 1), sametotal > 0 ? successes / total : 0.5rule (nowsuccessRateFrom, and NaN-safe because!(total_usage > 0)catches it), samerecent_ratings/recent_usageinputs, same four written columns. The single path folds its own reads intoSkillAggregateswithtotal_usage = successData.total, so the two paths cannot drift. isComplete = batch.length < batchSizeis untouched, which is correct for this PR's scope.
Nits, none blocking
SkillAggregates.skill_idis required by the builder input but never read bybuildScoreUpdate(). It is the map key in the bulk path and a value the single path has to invent. The pure builder's contract should not carry a query-shape field, soaggregates?: Omit<SkillAggregates, "skill_id">(or making it optional) keepsSkillAggregatesas the row type without implying the builder needs an identity.- The de-duplication stops at the math. Both modules still carry an identical global-stats select (
coalesce(avg(avg_rating), 0)plus fourcoalesce(max(...), 1)) and an identical max-trending subquery overratings/usage_stats, roughly 30 lines each. That is exactly the kind of copy that drifts next — window length, coalesce defaults — so a follow-uploadGlobalStats(db)/loadMaxTrendingRaw(db)helper would finish the job. Not this PR: it would push the diff back over the LOC goal it is fixing. - No parity test. The 8 builder cases pin the math, but nothing asserts that the two entry points write the same four columns for the same skill. A mocked-db test per entry point comparing the
.set()payload would have caught a wrong aggregate mapping — for examplerecent_usagepassed into the ratings slot — which is cheap insurance for a refactor whose entire claim is "no behaviour change".
Cross-PR
No file overlap with #169 (api.admin.recompute.ts plus new lib/admin/batch-limit.ts), so either merge order is clean. Note that #169 only bounds the route parameter, so after both land recomputeBatch still has no empty-batch guard — that residual is tracked in #28 and is not a defect in this PR.
Verdict
Sound, behaviour-preserving, and it closes #27's LOC problem without the third copy that a loop-only split would have created. Not merging: reviewDecision is empty and only an owner approval clears that. Merging on approval is fine as-is.
Posted by the github-maintain skill at 2026-10-31T13:08:00Z
What
recompute-all-skills.tswas 210 lines, over the repo's 200 LOC limit (issue #27). Splitting the loop alone would not fix the real problem: the Bayesian rating / composite score / trending score math existed twice, once here and once inrecompute-skill-scores.ts, and both copies had to agree on every future scoring change.This extracts the math into one pure function,
buildScoreUpdate()inapps/web/app/lib/leaderboard/recompute-score-update.ts, and points both callers at it.Changes
recompute-score-update.ts(113 lines) —buildScoreUpdate({ skill, aggregates, globalStats, maxTrendingRaw })returns exactly the four columns both callers write:bayesian_rating,composite_score,trending_score,favorite_count.recompute-score-update.test.ts(157 lines, 8 cases) — guard paths, Bayesian shrinkage toward the global mean, neutral0.5success rate, null rating columns.recompute-all-skills.ts210 → 166 lines — localSkillAggregatesmoved to the new module, per-skill loop body collapsed to a singlebuildScoreUpdate+db.update.recompute-skill-scores.ts140 → 117 lines — its separate reads folded intoSkillAggregatesand passed to the same builder. Export name and path unchanged, so the 5 routes and 4 test mocks thatvi.mockthis module are untouched.Behaviour
No change. The
Math.max(..., 1)guards are kept, so a zero-max trending window and null rating columns still produce finite numbers instead ofNaN. Because the math is now one function, the two paths can no longer drift apart.Verification
npx vitest run— 157 files, 2893 passed, 3 skippedpnpm typecheck— Done forskill-tree,contracts,cli,apps/webCloses #27