Browse Source

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
Dominique Merle 2 years ago
parent
commit
2fe66eb1bb
  1. 24
      addons/majority_judgment/MajorityJudgmentAbstractTallier.gd
  2. 32
      addons/majority_judgment/MajorityJudgmentCandidate.gd
  3. 67
      addons/majority_judgment/MajorityJudgmentCandidateMeritProfile.gd
  4. 12
      addons/majority_judgment/MajorityJudgmentCandidateTally.gd
  5. 40
      addons/majority_judgment/MajorityJudgmentGrade.gd
  6. 24
      addons/majority_judgment/MajorityJudgmentGrading.gd
  7. 35
      addons/majority_judgment/MajorityJudgmentJudgment.gd
  8. 27
      addons/majority_judgment/MajorityJudgmentParticipant.gd
  9. 126
      addons/majority_judgment/MajorityJudgmentPoll.gd
  10. 21
      addons/majority_judgment/MajorityJudgmentPollTally.gd
  11. 126
      addons/majority_judgment/talliers/MajorityJudgmentEasyTallier.gd

24
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

32
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

67
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

12
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

40
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

24
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

35
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

27
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

126
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()

21
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

126
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
Loading…
Cancel
Save