From 2fe66eb1bb1a04c5e6361861b764381a9f209449 Mon Sep 17 00:00:00 2001 From: domi41 Date: Sat, 8 Aug 2020 23:34:02 +0200 Subject: [PATCH] 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. --- .../MajorityJudgmentAbstractTallier.gd | 24 ++++ .../MajorityJudgmentCandidate.gd | 32 +++++ .../MajorityJudgmentCandidateMeritProfile.gd | 67 ++++++++++ .../MajorityJudgmentCandidateTally.gd | 12 ++ .../MajorityJudgmentGrade.gd | 40 ++++++ .../MajorityJudgmentGrading.gd | 24 ++++ .../MajorityJudgmentJudgment.gd | 35 +++++ .../MajorityJudgmentParticipant.gd | 27 ++++ .../majority_judgment/MajorityJudgmentPoll.gd | 126 ++++++++++++++++++ .../MajorityJudgmentPollTally.gd | 21 +++ .../talliers/MajorityJudgmentEasyTallier.gd | 126 ++++++++++++++++++ 11 files changed, 534 insertions(+) create mode 100644 addons/majority_judgment/MajorityJudgmentAbstractTallier.gd create mode 100644 addons/majority_judgment/MajorityJudgmentCandidate.gd create mode 100644 addons/majority_judgment/MajorityJudgmentCandidateMeritProfile.gd create mode 100644 addons/majority_judgment/MajorityJudgmentCandidateTally.gd create mode 100644 addons/majority_judgment/MajorityJudgmentGrade.gd create mode 100644 addons/majority_judgment/MajorityJudgmentGrading.gd create mode 100644 addons/majority_judgment/MajorityJudgmentJudgment.gd create mode 100644 addons/majority_judgment/MajorityJudgmentParticipant.gd create mode 100644 addons/majority_judgment/MajorityJudgmentPoll.gd create mode 100644 addons/majority_judgment/MajorityJudgmentPollTally.gd create mode 100644 addons/majority_judgment/talliers/MajorityJudgmentEasyTallier.gd diff --git a/addons/majority_judgment/MajorityJudgmentAbstractTallier.gd b/addons/majority_judgment/MajorityJudgmentAbstractTallier.gd new file mode 100644 index 0000000..eb2d97f --- /dev/null +++ b/addons/majority_judgment/MajorityJudgmentAbstractTallier.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 diff --git a/addons/majority_judgment/MajorityJudgmentCandidate.gd b/addons/majority_judgment/MajorityJudgmentCandidate.gd new file mode 100644 index 0000000..0917bf5 --- /dev/null +++ b/addons/majority_judgment/MajorityJudgmentCandidate.gd @@ -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 diff --git a/addons/majority_judgment/MajorityJudgmentCandidateMeritProfile.gd b/addons/majority_judgment/MajorityJudgmentCandidateMeritProfile.gd new file mode 100644 index 0000000..04100c4 --- /dev/null +++ b/addons/majority_judgment/MajorityJudgmentCandidateMeritProfile.gd @@ -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 + diff --git a/addons/majority_judgment/MajorityJudgmentCandidateTally.gd b/addons/majority_judgment/MajorityJudgmentCandidateTally.gd new file mode 100644 index 0000000..838cb56 --- /dev/null +++ b/addons/majority_judgment/MajorityJudgmentCandidateTally.gd @@ -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 diff --git a/addons/majority_judgment/MajorityJudgmentGrade.gd b/addons/majority_judgment/MajorityJudgmentGrade.gd new file mode 100644 index 0000000..0d9e32b --- /dev/null +++ b/addons/majority_judgment/MajorityJudgmentGrade.gd @@ -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 diff --git a/addons/majority_judgment/MajorityJudgmentGrading.gd b/addons/majority_judgment/MajorityJudgmentGrading.gd new file mode 100644 index 0000000..43f5182 --- /dev/null +++ b/addons/majority_judgment/MajorityJudgmentGrading.gd @@ -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 diff --git a/addons/majority_judgment/MajorityJudgmentJudgment.gd b/addons/majority_judgment/MajorityJudgmentJudgment.gd new file mode 100644 index 0000000..c2065c3 --- /dev/null +++ b/addons/majority_judgment/MajorityJudgmentJudgment.gd @@ -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 + diff --git a/addons/majority_judgment/MajorityJudgmentParticipant.gd b/addons/majority_judgment/MajorityJudgmentParticipant.gd new file mode 100644 index 0000000..d469b49 --- /dev/null +++ b/addons/majority_judgment/MajorityJudgmentParticipant.gd @@ -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 diff --git a/addons/majority_judgment/MajorityJudgmentPoll.gd b/addons/majority_judgment/MajorityJudgmentPoll.gd new file mode 100644 index 0000000..6b5fc10 --- /dev/null +++ b/addons/majority_judgment/MajorityJudgmentPoll.gd @@ -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() diff --git a/addons/majority_judgment/MajorityJudgmentPollTally.gd b/addons/majority_judgment/MajorityJudgmentPollTally.gd new file mode 100644 index 0000000..bcec376 --- /dev/null +++ b/addons/majority_judgment/MajorityJudgmentPollTally.gd @@ -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 + diff --git a/addons/majority_judgment/talliers/MajorityJudgmentEasyTallier.gd b/addons/majority_judgment/talliers/MajorityJudgmentEasyTallier.gd new file mode 100644 index 0000000..5b5e070 --- /dev/null +++ b/addons/majority_judgment/talliers/MajorityJudgmentEasyTallier.gd @@ -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 + +