Skip to content

Commit 4a23357

Browse files
authored
Mj (#1231)
* ah MJ * sighh....
1 parent ddd6c7b commit 4a23357

File tree

8 files changed

+466
-0
lines changed

8 files changed

+466
-0
lines changed
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
class ShowcaseController < ApplicationController
2+
before_action :authenticate_user!
3+
4+
def new
5+
@project = Showcase::Matchmaker.new(user: current_user).pick
6+
7+
if @project.nil?
8+
redirect_to explore_path, alert: "No eligible projects available right now." and return
9+
end
10+
11+
ActiveRecord::Associations::Preloader
12+
.new(records: [ @project ], associations: [ { banner_attachment: :blob } ])
13+
.call
14+
15+
@devlogs = Devlog.where(project_id: @project.id)
16+
.includes({ user: :user_badges }, { file_attachment: :blob })
17+
.order(:created_at)
18+
19+
@grade_labels = VoteMf::GRADE_LABELS
20+
@criteria = VoteMf::CRITERIA
21+
@vote_mf = VoteMf.new(project: @project, voter: current_user)
22+
23+
session[:showcase_started_at] = Time.current.to_i
24+
end
25+
26+
def create
27+
# yes, this is indeed vibecoded. I do not have motivation to work on SoM's codebase. check out battlemage tho. that said, i wrote the matchmakign service and the models by hand
28+
@project = Project.find(params.require(:project_id))
29+
if @project.user_id == current_user.id
30+
redirect_to showcase_path, alert: "You cannot vote on your own project." and return
31+
end
32+
33+
raw_ballot = params.require(:vote_mf).permit(ballot: {})[:ballot] || {}
34+
35+
time_spt_ms = nil
36+
if session[:showcase_started_at].present?
37+
begin
38+
started_at = Time.at(session[:showcase_started_at].to_i)
39+
time_spt_ms = ((Time.current - started_at) * 1000).to_i.clamp(0, 86_400_000)
40+
rescue StandardError
41+
time_spt_ms = nil
42+
end
43+
end
44+
45+
@vote_mf = VoteMf.new(
46+
project: @project,
47+
voter: current_user,
48+
ballot: raw_ballot,
49+
time_spent_voting_ms: time_spt_ms
50+
)
51+
52+
if @vote_mf.save
53+
session.delete(:showcase_started_at)
54+
redirect_to showcase_path, notice: "Thanks! Your vote was recorded."
55+
else
56+
ActiveRecord::Associations::Preloader
57+
.new(records: [ @project ], associations: [ { banner_attachment: :blob } ])
58+
.call
59+
@devlogs = Devlog.where(project_id: @project.id)
60+
.includes({ user: :user_badges }, { file_attachment: :blob })
61+
.order(:created_at)
62+
@grade_labels = VoteMf::GRADE_LABELS
63+
@criteria = VoteMf::CRITERIA
64+
flash.now[:alert] = @vote_mf.errors.full_messages.join(", ")
65+
render :new, status: :unprocessable_entity
66+
end
67+
end
68+
end

app/models/vote_mf.rb

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# == Schema Information
2+
#
3+
# Table name: vote_mfs
4+
#
5+
# id :bigint not null, primary key
6+
# ballot :jsonb not null
7+
# time_spent_voting_ms :integer
8+
# created_at :datetime not null
9+
# updated_at :datetime not null
10+
# project_id :bigint not null
11+
# voter_id :bigint not null
12+
#
13+
# Indexes
14+
#
15+
# index_vote_mfs_on_project_id (project_id)
16+
# index_vote_mfs_on_voter_id (voter_id)
17+
#
18+
# Foreign Keys
19+
#
20+
# fk_rails_... (project_id => projects.id)
21+
# fk_rails_... (voter_id => users.id)
22+
#
23+
class VoteMf < ApplicationRecord
24+
belongs_to :project
25+
belongs_to :voter, class_name: "User"
26+
27+
CRITERIA = %w[technical creativity storytelling originality].freeze
28+
29+
# 6-point scale, because 5-point scale leads to pepople picking the nice middle. "In 2006 the majority judgment experiment used five grades (see table 21.1b).
30+
# This was not the optimal choice; too often, judges waffled by giving the middle
31+
# grade, Good."
32+
33+
GRADE_LABELS = [
34+
"Poor", # 0
35+
"Mediocre", # 1
36+
"Fair", # 2
37+
"Good", # 3
38+
"Very good", # 4
39+
"Excellent" # 5
40+
].freeze
41+
42+
GRADE_LABEL_TO_INT = GRADE_LABELS.each_with_index.to_h.transform_keys { |k| k.downcase }
43+
44+
before_validation :normalize_ballot!
45+
46+
validates :time_spent_voting_ms, numericality: { greater_than: 0 }
47+
validates :voter_id, uniqueness: { scope: :project_id }
48+
validate :validate_ballot
49+
50+
# it will return a 0.0..1.0 (b/w 0 and 1) normalized composite score across all criteria – sum / n_critieria * (label - 1)
51+
def normalized_total_score
52+
values = ballot.is_a?(Hash) ? ballot.values : []
53+
return nil if values.empty?
54+
55+
int_values = values.map(&:to_i)
56+
max_total = (CRITERIA.size * (GRADE_LABELS.size - 1)).to_f
57+
return 0.0 if max_total <= 0
58+
59+
(int_values.sum / max_total).clamp(0.0, 1.0)
60+
end
61+
62+
def ballot_labels
63+
return {} unless ballot.is_a?(Hash)
64+
ballot.to_h.transform_values do |v|
65+
int = v.is_a?(String) ? GRADE_LABEL_TO_INT[v.downcase] : v
66+
GRADE_LABELS[int.to_i] if int.is_a?(Integer) && int.between?(0, 5)
67+
end.compact
68+
end
69+
70+
private
71+
72+
def normalize_ballot!
73+
b = ballot.dup
74+
normalized = {}
75+
CRITERIA.each do |criterion|
76+
raw = b[criterion]
77+
next if raw.nil?
78+
value = GRADE_LABEL_TO_INT[raw.strip.downcase]
79+
normalized[criterion] = value unless value.nil?
80+
end
81+
self.ballot = normalized
82+
end
83+
84+
def validate_ballot
85+
errors.add(:ballot, "must be a JSON object") and return unless ballot.is_a?(Hash)
86+
87+
missing = CRITERIA - ballot.keys.map(&:to_s)
88+
errors.add(:ballot, "is missing required criteria: #{missing.join(', ')}") unless missing.empty?
89+
90+
ballot.each do |criterion, value|
91+
unless CRITERIA.include?(criterion.to_s)
92+
errors.add(:ballot, "contains unknown criterion '#{criterion}'")
93+
next
94+
end
95+
end
96+
end
97+
end
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
module Showcase
2+
class Matchmaker
3+
# my SO is a great matchmaker. hit me up on slack if you want lore!
4+
5+
MAX_COMPOSITE_STDDEV = 0.5
6+
TARGET_VOTERS = 15
7+
WEIGHT_EXPOSURE = 0.6
8+
WEIGHT_UNCERTAINTY = 0.4
9+
SOFTMAX_TEMPERATURE = 1.0
10+
11+
def initialize(user:)
12+
@user = user
13+
end
14+
15+
def pick
16+
pool_project_ids = build_pool_project_ids
17+
return nil if pool_project_ids.empty?
18+
19+
user_project = Project.where(id: pool_project_ids, user_id: @user.id).pluck(:id)
20+
already_voted = VoteMf.where(voter_id: @user.id, project_id: pool_project_ids).pluck(:project_id)
21+
22+
excluded_ids = (user_project + already_voted).uniq
23+
candidate = pool_project_ids - excluded_ids
24+
return nil if candidate.empty?
25+
26+
exposure_by_id = exposure_scores(candidate)
27+
uncertainty_by_id = uncertainty_scores(candidate)
28+
29+
scores = candidate.index_with do |pid|
30+
(WEIGHT_EXPOSURE * (exposure_by_id[pid] || 0.0)) + (WEIGHT_UNCERTAINTY * (uncertainty_by_id[pid] || 0.0))
31+
end
32+
33+
chosen_id = softmax(scores)
34+
Project.find_by(id: chosen_id)
35+
end
36+
37+
private
38+
39+
def build_pool_project_ids
40+
p = [ 2160,
41+
1779,
42+
9722,
43+
11764,
44+
792,
45+
2701,
46+
11177,
47+
11979,
48+
1469,
49+
12938,
50+
991,
51+
3423,
52+
1598,
53+
6392,
54+
12342,
55+
13073,
56+
10944,
57+
13782,
58+
169,
59+
6838,
60+
1775,
61+
914,
62+
1014,
63+
2521,
64+
133,
65+
8597,
66+
12542,
67+
1727,
68+
9530,
69+
947,
70+
10359,
71+
837,
72+
1932,
73+
12484,
74+
4041,
75+
53,
76+
398,
77+
7260,
78+
10992,
79+
4192,
80+
2179,
81+
2290,
82+
9969,
83+
12762,
84+
2365,
85+
5812,
86+
2753,
87+
13844,
88+
8326,
89+
6472,
90+
12114,
91+
3529,
92+
29,
93+
13227,
94+
1730,
95+
11304,
96+
1537,
97+
8244,
98+
161,
99+
196,
100+
4283,
101+
10458,
102+
4637,
103+
1131,
104+
13321,
105+
2787,
106+
4032,
107+
723,
108+
11162,
109+
7236,
110+
311,
111+
2470,
112+
7613,
113+
12158,
114+
13678,
115+
4907,
116+
2181,
117+
9065,
118+
4310,
119+
10026,
120+
2429,
121+
13952,
122+
3152,
123+
12344,
124+
3888,
125+
5038,
126+
664,
127+
3761,
128+
15,
129+
11442,
130+
6059,
131+
8920,
132+
5328,
133+
6737,
134+
12728,
135+
8994,
136+
791,
137+
5187,
138+
10803,
139+
5942 ]
140+
p & Project.where(id: p).pluck(:id)
141+
end
142+
143+
def exposure_scores(project_ids)
144+
return {} if project_ids.empty?
145+
146+
voters_count = VoteMf.where(project_id: project_ids).group(:project_id).distinct.count(:voter_id)
147+
project_ids.index_with do |p|
148+
v = voters_count[p].to_i
149+
((TARGET_VOTERS - v) / TARGET_VOTERS.to_f).clamp(0.0, 1.0)
150+
end
151+
end
152+
153+
def uncertainty_scores(project_ids)
154+
return {} if project_ids.empty?
155+
156+
scores_by_project_id = Hash.new { |h, k| h[k] = [] }
157+
VoteMf.where(project_id: project_ids).select(:project_id, :ballot).find_each(batch_size: 50) do |vmf|
158+
s = vmf.normalized_total_score
159+
scores_by_project_id[vmf.project_id] << s if s
160+
end
161+
162+
project_ids.index_with do |pid|
163+
scores = scores_by_project_id[pid]
164+
next 1.0 if scores.empty?
165+
166+
mean = scores.sum.to_f / scores.size
167+
var = scores.sum { |x| (x - mean) ** 2 } / scores.size.to_f
168+
std = Math.sqrt(var)
169+
(std / MAX_COMPOSITE_STDDEV).clamp(0.0, 1.0)
170+
end
171+
end
172+
173+
def softmax(scores_by_id)
174+
return nil if scores_by_id.empty?
175+
176+
ids = scores_by_id.keys
177+
values = ids.map { |id| scores_by_id[id].to_f }
178+
179+
max_v = values.max
180+
temperature = [ SOFTMAX_TEMPERATURE.to_f, 1e-6 ].max
181+
exps = values.map { |v| Math.exp((v - max_v) / temperature) }
182+
sum = exps.sum
183+
return ids[values.index(max_v)] if sum <= 0.0
184+
185+
probs = exps.map { |x| x / sum }
186+
r = rand
187+
cumulative = 0.0
188+
ids.each_with_index do |id, idx|
189+
cumulative += probs[idx]
190+
return id if r <= cumulative
191+
end
192+
ids.last
193+
end
194+
end
195+
end

0 commit comments

Comments
 (0)