All these files will find their way into a plugin, once they are stable, reviewed and documented.master
parent
f79b59f82b
commit
2fe66eb1bb
@ -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
|
||||
|
||||
|
Loading…
Reference in new issue