Browse Source
feat: initial implementation of Majority Judgment in Gdscript
feat: initial implementation of Majority Judgment in Gdscript
All these files will find their way into a plugin, once they are stable, reviewed and documented.master
11 changed files with 534 additions and 0 deletions
-
24addons/majority_judgment/MajorityJudgmentAbstractTallier.gd
-
32addons/majority_judgment/MajorityJudgmentCandidate.gd
-
67addons/majority_judgment/MajorityJudgmentCandidateMeritProfile.gd
-
12addons/majority_judgment/MajorityJudgmentCandidateTally.gd
-
40addons/majority_judgment/MajorityJudgmentGrade.gd
-
24addons/majority_judgment/MajorityJudgmentGrading.gd
-
35addons/majority_judgment/MajorityJudgmentJudgment.gd
-
27addons/majority_judgment/MajorityJudgmentParticipant.gd
-
126addons/majority_judgment/MajorityJudgmentPoll.gd
-
21addons/majority_judgment/MajorityJudgmentPollTally.gd
-
126addons/majority_judgment/talliers/MajorityJudgmentEasyTallier.gd
@ -0,0 +1,24 @@ |
|||
extends Resource |
|||
class_name MajorityJudgmentAbstractTallier |
|||
|
|||
|
|||
""" |
|||
Interface pattern, kind of. |
|||
This will allow us to provide multiple tallying algorithms. |
|||
|
|||
To make you own: |
|||
Extend this, override `tally()`, and register it. (undecided about how yet) |
|||
|
|||
Expected algorithms: |
|||
- Easy (expensive but simple to understand) |
|||
- Fast (optimized for speed and scalability) |
|||
- Liquid (handles delegations without loops) |
|||
- Quantic (handles delegations with loops) |
|||
- Empathic (handles non-sentient) |
|||
- Holistic (handles everything) (trololol) |
|||
""" |
|||
|
|||
|
|||
func tally(poll) -> MajorityJudgmentPollTally: |
|||
assert(false, "Override me") |
|||
return null |
@ -0,0 +1,32 @@ |
|||
extends Resource |
|||
class_name MajorityJudgmentCandidate |
|||
|
|||
|
|||
""" |
|||
The thing that is judged in the poll, |
|||
receiving grades from participants. |
|||
|
|||
## Alternative names |
|||
- Candidate: from 'candor' |
|||
- Option: ambiguous, since polls may have options/settings |
|||
- Proposition: this one is good, no? |
|||
""" |
|||
|
|||
|
|||
export(String) var name:String setget set_name, get_name |
|||
|
|||
|
|||
func set_name(__name:String) -> void: |
|||
name = __name |
|||
|
|||
|
|||
func get_name() -> String: |
|||
if null == name: |
|||
return 'Unknown' |
|||
return name |
|||
|
|||
|
|||
static func make(__name:String): # -> MajorityJudgmentCandidate: |
|||
var candidate = load('res://addons/majority_judgment/MajorityJudgmentCandidate.gd').new() |
|||
candidate.set_name(__name) |
|||
return candidate |
@ -0,0 +1,67 @@ |
|||
extends Resource |
|||
class_name MajorityJudgmentCandidateMeritProfile |
|||
|
|||
|
|||
""" |
|||
The merit profile of a candidate is obtained |
|||
by putting on a single line the judgments they received, |
|||
sorted by increasing (or decreasing) grade. |
|||
|
|||
This is built by the Talliers. |
|||
|
|||
Overzealous object-oriented approach yet again, perhaps. |
|||
""" |
|||
|
|||
|
|||
export(Resource) var candidate_tally #:MajorityJudgmentCandidateTally |
|||
|
|||
|
|||
# For each grade (worst to best), the amount of judgments received |
|||
export(Array, int) var grades |
|||
|
|||
|
|||
#export(int) var grades_amount |
|||
|
|||
|
|||
func count_judgments() -> int: |
|||
var amount := 0 |
|||
for amount_for_grade in self.grades: |
|||
amount += amount_for_grade |
|||
return amount |
|||
|
|||
|
|||
func get_median() -> int: |
|||
""" |
|||
Returns the grade index, not a Grade instance. |
|||
0 = worst grade |
|||
|
|||
Returns -1 if there are no judgments. |
|||
""" |
|||
assert(self.grades) |
|||
var total := count_judgments() |
|||
if 0 == total: |
|||
return -1 |
|||
|
|||
var middle := 0 |
|||
if 0 == total % 2: # total is even |
|||
middle = total / 2 |
|||
else: # total is odd |
|||
middle = (total + 1) / 2 |
|||
assert(middle > 0) |
|||
|
|||
var amount := 0 |
|||
|
|||
for i in range(self.grades.size()): |
|||
assert(0 <= self.grades[i], "Negative grade amount… What? Cheater!") |
|||
amount += self.grades[i] |
|||
if amount >= middle: |
|||
return i |
|||
|
|||
assert(false, "That should not happen") |
|||
return -1 |
|||
|
|||
|
|||
func remove_one_judgment(grade_index:int) -> void: |
|||
assert(0 < self.grades[grade_index]) |
|||
self.grades[grade_index] -= 1 |
|||
|
@ -0,0 +1,12 @@ |
|||
extends Resource |
|||
class_name MajorityJudgmentCandidateTally |
|||
|
|||
|
|||
export(Resource) var poll |
|||
export(Resource) var candidate |
|||
export(Resource) var merit_profile |
|||
|
|||
export(int) var creation_timestamp:int |
|||
|
|||
export(Resource) var median_grade |
|||
export(int) var position:int |
@ -0,0 +1,40 @@ |
|||
extends Resource |
|||
class_name MajorityJudgmentGrade |
|||
|
|||
|
|||
""" |
|||
A single grade. |
|||
Could be just an int or a string, |
|||
but let's go full oriented object. |
|||
|
|||
|
|||
PERHAPS ALREADY DEPRECATED |
|||
Supports localization (l10n). |
|||
Default language is Esperanto. Videoludemuloj! |
|||
""" |
|||
|
|||
|
|||
# Perhaps we'll drop these shenanigans |
|||
# and use tr() instead, we'll see. |
|||
export(Dictionary) var localized_names:Dictionary |
|||
|
|||
|
|||
# This is set by the gradation this grade is included in |
|||
# 0: worst grade |
|||
# more: better grade (up to gradation size minus one) |
|||
export(int) var worth := 0 |
|||
|
|||
|
|||
# We use a factory, since ResourceLoader won't like parameters in _init() |
|||
static func make(__name): |
|||
# var grade = MajorityJudgmentGrade.new() # cyclic :3 |
|||
var grade = load("res://addons/majority_judgment/MajorityJudgmentGrade.gd").new() |
|||
grade.set_name(__name) |
|||
return grade |
|||
|
|||
|
|||
func set_name(__name): |
|||
if __name is String: |
|||
__name = {'eo_EO': __name} |
|||
assert(__name is Dictionary) |
|||
self.localized_names = __name |
@ -0,0 +1,24 @@ |
|||
extends Resource |
|||
class_name MajorityJudgmentGrading |
|||
|
|||
""" |
|||
An ordered list of grades, from worst to best. |
|||
It's VERY IMPORTANT that the grades be NON-AMBIGUOUS, |
|||
that everyone would sort the grades in the same order |
|||
if they were given shuffled. |
|||
""" |
|||
|
|||
|
|||
# Array of MajorityJudgmentGrade |
|||
# From worst to best |
|||
export(Array) var grades:Array setget set_grades, get_grades |
|||
|
|||
|
|||
func set_grades(_grades:Array): |
|||
assert(_grades, "Provide an array of MajorityJudgmentGrade") |
|||
grades = _grades |
|||
for i in range(grades.size()): |
|||
grades[i].worth = i |
|||
|
|||
func get_grades() -> Array: |
|||
return grades |
@ -0,0 +1,35 @@ |
|||
extends Resource |
|||
class_name MajorityJudgmentJudgment |
|||
|
|||
|
|||
export(Resource) var participant setget set_participant, get_participant |
|||
export(Resource) var candidate setget set_candidate, get_candidate |
|||
export(Resource) var grade setget set_grade, get_grade |
|||
|
|||
|
|||
func set_participant(__participant:MajorityJudgmentParticipant) -> void: |
|||
participant = __participant |
|||
|
|||
|
|||
func get_participant() -> MajorityJudgmentParticipant: |
|||
assert(participant, "Judgment has no participant. Something went horribly WRONG.") |
|||
return participant |
|||
|
|||
|
|||
func set_candidate(__candidate:MajorityJudgmentCandidate) -> void: |
|||
candidate = __candidate |
|||
|
|||
|
|||
func get_candidate() -> MajorityJudgmentCandidate: |
|||
assert(candidate, "Judgment has no candidate. Something went horribly WRONG.") |
|||
return candidate |
|||
|
|||
|
|||
func set_grade(__grade:MajorityJudgmentGrade) -> void: |
|||
grade = __grade |
|||
|
|||
|
|||
func get_grade() -> MajorityJudgmentGrade: |
|||
assert(grade, "Judgment has no grade. Something went horribly WRONG.") |
|||
return grade |
|||
|
@ -0,0 +1,27 @@ |
|||
extends Resource |
|||
class_name MajorityJudgmentParticipant |
|||
|
|||
|
|||
""" |
|||
Citizens of the world, unite! |
|||
""" |
|||
|
|||
|
|||
export(String) var name:String setget set_name, get_name |
|||
|
|||
|
|||
static func make(__name): |
|||
var participant = load("res://addons/majority_judgment/MajorityJudgmentParticipant.gd").new() |
|||
participant.set_name(__name) |
|||
return participant |
|||
|
|||
|
|||
func set_name(__name:String) -> void: |
|||
# FIXME: sanitize |
|||
name = __name |
|||
|
|||
|
|||
func get_name() -> String: |
|||
if null == name: |
|||
return 'Anonymous' |
|||
return name |
@ -0,0 +1,126 @@ |
|||
extends Resource |
|||
class_name MajorityJudgmentPoll |
|||
|
|||
""" |
|||
A poll with multiple candidates, where each participant |
|||
gives a single grade to each of the candidates. |
|||
The candidates are then sorted by their median grade. |
|||
|
|||
https://en.wikipedia.org/wiki/Majority_Judgment |
|||
The constitutive details of the poll are up to you. |
|||
""" |
|||
|
|||
# Minimum length: 4 unicode characters (? TBD) |
|||
# Maximum length: 256 unicode characters (? TBD) |
|||
export(String) var title:String setget set_title, get_title |
|||
# > How do we localize this? |
|||
# Something like this, perhaps? |
|||
# title_of_locale = { |
|||
# 'en_US': "President of the United States of America in 2020", |
|||
# 'fr_FR': "Président des États-Unis d'Amérique en 2020", |
|||
# … |
|||
# } |
|||
#export(Dictionary) var localized_titles:Dictionary |
|||
# or perhaps |
|||
# https://docs.godotengine.org/en/stable/tutorials/i18n/internationalizing_games.html |
|||
|
|||
|
|||
export(Resource) var grading #:MajorityJudgmentGrading |
|||
|
|||
|
|||
# Array of MajorityJudgmentCandidate |
|||
export(Array, Resource) var candidates:Array setget set_candidates, get_candidates |
|||
|
|||
|
|||
# Array of MajorityJudgmentJudgments |
|||
export(Array, Resource) var judgments:Array setget set_judgments, get_judgments |
|||
|
|||
|
|||
|
|||
# Don't pass parameters in _init(). |
|||
# ResourceLoader.load() won't like it. |
|||
func _init(): |
|||
pass |
|||
|
|||
|
|||
# Instead, use a factory approach |
|||
#static func make(__title, __grading): |
|||
|
|||
|
|||
func set_title(__title:String) -> void: |
|||
title = __title |
|||
|
|||
|
|||
func get_title() -> String: |
|||
if null == title: |
|||
return '' |
|||
return title |
|||
|
|||
|
|||
func set_grading(__grading:MajorityJudgmentGrading) -> void: |
|||
grading = __grading |
|||
|
|||
|
|||
func get_grading() -> MajorityJudgmentGrading: |
|||
assert(grading, "Poll has no grading.") |
|||
return grading |
|||
|
|||
|
|||
func set_candidates(__candidates:Array) -> void: |
|||
assert(__candidates, "Poll requires at least one candidate.") |
|||
candidates = __candidates |
|||
|
|||
|
|||
func add_candidate(candidate:MajorityJudgmentCandidate) -> void: |
|||
if not candidates: |
|||
candidates = Array() |
|||
candidates.append(candidate) |
|||
|
|||
|
|||
func get_candidates() -> Array: |
|||
assert(candidates, "Poll has no candidates.") |
|||
return candidates |
|||
|
|||
|
|||
func set_judgments(__judgments:Array) -> void: |
|||
judgments = __judgments |
|||
|
|||
|
|||
func add_judgment(judgment:MajorityJudgmentJudgment) -> void: |
|||
if not judgments: |
|||
judgments = Array() |
|||
for i in range(judgments.size()): |
|||
var existing_judgment = judgments[i] |
|||
if ( |
|||
(existing_judgment.participant == judgment.participant) |
|||
and |
|||
(existing_judgment.candidate == judgment.candidate) |
|||
): |
|||
judgments[i] = judgment |
|||
judgments.append(judgment) |
|||
|
|||
|
|||
func get_judgments() -> Array: |
|||
assert(judgments, "Poll has no judgments.") |
|||
return judgments |
|||
|
|||
|
|||
func tally() -> MajorityJudgmentPollTally: |
|||
# Pick relevant tallier from settings later on |
|||
var tallier = MajorityJudgmentEasyTallier.new() |
|||
# var tallier = MajorityJudgmentLiquidTallier.new() |
|||
return tallier.tally(self) |
|||
|
|||
|
|||
func get_participants() -> Array: |
|||
var participants := Array() |
|||
for judgment in judgments: |
|||
var participant : MajorityJudgmentParticipant = judgment.participant |
|||
if not participants.has(participant): |
|||
participants.append(participant) |
|||
return participants |
|||
|
|||
|
|||
|
|||
func count_participants() -> int: |
|||
return get_participants().size() |
@ -0,0 +1,21 @@ |
|||
extends Resource |
|||
class_name MajorityJudgmentPollTally |
|||
|
|||
""" |
|||
Generated by one of the implementations of |
|||
MajorityJudgmentAbstractTallier. |
|||
|
|||
It should hold all the data we need to display the results. |
|||
""" |
|||
|
|||
|
|||
export(Resource) var poll #:MajorityJudgmentPoll |
|||
|
|||
|
|||
# Array of MajorityJudgmentCandidateTally |
|||
# Sorted from best to worst, sometimes arbitrary |
|||
# as some candidates may have the exact same position, |
|||
# because they have the exact same merit profile, |
|||
# so check each MajorityJudgmentCandidateTally.position. |
|||
export(Array, Resource) var candidates_tallies:Array |
|||
|
@ -0,0 +1,126 @@ |
|||
extends MajorityJudgmentAbstractTallier |
|||
class_name MajorityJudgmentEasyTallier |
|||
|
|||
|
|||
""" |
|||
|
|||
|
|||
Here's a pseudo algorithm to compare two candidates, |
|||
assuming they received the same number of judgments: |
|||
|
|||
compare(candidate_a, candidate_b): |
|||
00. if no judgments -> done (equality) |
|||
01. compute median grades of a and b |
|||
02. if different -> done |
|||
03. remove one judgment of the median grade to both a and b |
|||
04. go to 00 |
|||
|
|||
See __sort_candidate_tallies below for an implementation |
|||
|
|||
""" |
|||
|
|||
|
|||
func tally(poll) -> MajorityJudgmentPollTally: |
|||
|
|||
var participants_amount : int = poll.count_participants() |
|||
var candidates_tallies := Array() |
|||
|
|||
for candidate in poll.candidates: |
|||
var candidate_tally := MajorityJudgmentCandidateTally.new() |
|||
candidate_tally.poll = poll |
|||
candidate_tally.candidate = candidate |
|||
var merit_profile := MajorityJudgmentCandidateMeritProfile.new() |
|||
var amount_of_judgments := 0 |
|||
var amount_of_judgments_per_grade := Array() |
|||
for grade in poll.grading.grades: |
|||
var amount = 0 |
|||
for judgment in poll.judgments: |
|||
if judgment.candidate != candidate: |
|||
continue |
|||
if judgment.grade != grade: |
|||
continue |
|||
amount += 1 |
|||
amount_of_judgments += amount |
|||
amount_of_judgments_per_grade.append(amount) |
|||
|
|||
# Add missing judgments as TO_REJECT |
|||
assert(amount_of_judgments <= participants_amount) |
|||
var amount_missing = participants_amount - amount_of_judgments |
|||
if amount_missing: |
|||
amount_of_judgments_per_grade[0] += amount_missing |
|||
|
|||
merit_profile.grades = amount_of_judgments_per_grade |
|||
candidate_tally.merit_profile = merit_profile |
|||
|
|||
candidates_tallies.append(candidate_tally) |
|||
|
|||
candidates_tallies.sort_custom(self, "__sort_candidate_tallies") |
|||
|
|||
# Deduce the position |
|||
var current_position := 1 |
|||
for p in range(candidates_tallies.size()): |
|||
if 0 == p: |
|||
candidates_tallies[p].position = 1 |
|||
else: |
|||
if 0 != __compare_candidate_tallies(candidates_tallies[p], candidates_tallies[p-1]): |
|||
current_position = p + 1 |
|||
candidates_tallies[p].position = current_position |
|||
|
|||
|
|||
var poll_tally = MajorityJudgmentPollTally.new() |
|||
poll_tally.poll = self |
|||
poll_tally.candidates_tallies = candidates_tallies |
|||
return poll_tally |
|||
|
|||
|
|||
# Compare function for a sort from best to worst (descending) |
|||
# This function is expected to return true if a < b, when ascending |
|||
# so we'll return true if a > b, since we're descending. |
|||
# Wrap for the boolean API of Array.custom_sort(), basically. |
|||
func __sort_candidate_tallies(a, b) -> bool: |
|||
if 0 > __compare_candidate_tallies(a, b): |
|||
return true |
|||
return false |
|||
|
|||
|
|||
# a < b (median_a > median_b) --> -1 |
|||
# a = b (median_a = median_b) --> 0 |
|||
# a > b (median_a < median_b) --> +1 |
|||
func __compare_candidate_tallies(a, b) -> int: |
|||
assert(a, "Candidate A's tally is undefined.") |
|||
assert(b, "Candidate B's tally is undefined.") |
|||
|
|||
var profile_a = a.merit_profile.duplicate() |
|||
var profile_b = b.merit_profile.duplicate() |
|||
|
|||
var amount_of_judgments_a = profile_a.count_judgments() |
|||
var amount_of_judgments_b = profile_b.count_judgments() |
|||
|
|||
assert( |
|||
amount_of_judgments_a == amount_of_judgments_b, |
|||
"Judgments amounts mismatch between candidates tallies A and B." |
|||
) |
|||
|
|||
# 00. if no more judgments -> done (equality) |
|||
while (amount_of_judgments_a > 0): |
|||
# 01. compute median grades of a and b |
|||
var median_a = profile_a.get_median() |
|||
var median_b = profile_b.get_median() |
|||
assert(0 <= median_a, "Median of A not found.") |
|||
assert(0 <= median_b, "Median of B not found.") |
|||
# 02. if different -> done |
|||
if median_a < median_b: |
|||
return 1 |
|||
if median_a > median_b: |
|||
return -1 |
|||
assert(median_a == median_b, "Sanity") |
|||
# 03. remove one judgment of the median grade to both a and b |
|||
profile_a.remove_one_judgment(median_a) |
|||
profile_b.remove_one_judgment(median_b) |
|||
# 04. go to 00 |
|||
amount_of_judgments_a = profile_a.count_judgments() |
|||
amount_of_judgments_b = profile_b.count_judgments() # for good measure |
|||
|
|||
return 0 # a == b |
|||
|
|||
|
Write
Preview
Loading…
Cancel
Save
Reference in new issue