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