diff --git a/src/main/java/fr/mieuxvoter/mj/CollectedTally.java b/src/main/java/fr/mieuxvoter/mj/CollectedTally.java new file mode 100644 index 0000000..2af1cd7 --- /dev/null +++ b/src/main/java/fr/mieuxvoter/mj/CollectedTally.java @@ -0,0 +1,72 @@ +package fr.mieuxvoter.mj; + +import java.math.BigInteger; + +public class CollectedTally implements TallyInterface { + + Integer amountOfProposals = 0; + Integer amountOfGrades = 0; + + ProposalTally[] proposalsTallies; + + public CollectedTally(Integer amountOfProposals, Integer amountOfGrades) { + setAmountOfProposals(amountOfProposals); + setAmountOfGrades(amountOfGrades); + + proposalsTallies = new ProposalTally[amountOfProposals]; + for (int i = 0; i < amountOfProposals; i++) { + ProposalTally proposalTally = new ProposalTally(); + Integer[] tally = new Integer[amountOfGrades]; + for (int j = 0; j < amountOfGrades; j++) { + tally[j] = 0; + } + proposalTally.setTally(tally); + proposalsTallies[i] = proposalTally; + } + } + + @Override + public ProposalTallyInterface[] getProposalsTallies() { + return proposalsTallies; + } + + @Override + public BigInteger getAmountOfJudges() { + return guessAmountOfJudges(); + } + + @Override + public Integer getAmountOfProposals() { + return this.amountOfProposals; + } + + public void setAmountOfProposals(Integer amountOfProposals) { + this.amountOfProposals = amountOfProposals; + } + + public Integer getAmountOfGrades() { + return amountOfGrades; + } + + public void setAmountOfGrades(Integer amountOfGrades) { + this.amountOfGrades = amountOfGrades; + } + + protected BigInteger guessAmountOfJudges() { + BigInteger amountOfJudges = BigInteger.ZERO; + for (ProposalTallyInterface proposalTally : getProposalsTallies()) { + amountOfJudges = proposalTally.getAmountOfJudgments().max(amountOfJudges); + } + return amountOfJudges; + } + + public void collect(Integer proposal, Integer grade) { + assert (0 <= proposal); + assert (amountOfProposals > proposal); + assert (0 <= grade); + assert (amountOfGrades > grade); + + BigInteger[] tally = proposalsTallies[proposal].getTally(); + tally[grade] = tally[grade].add(BigInteger.ONE); + } +} diff --git a/src/main/java/fr/mieuxvoter/mj/DefaultGradeTally.java b/src/main/java/fr/mieuxvoter/mj/DefaultGradeTally.java new file mode 100644 index 0000000..613de2a --- /dev/null +++ b/src/main/java/fr/mieuxvoter/mj/DefaultGradeTally.java @@ -0,0 +1,47 @@ +package fr.mieuxvoter.mj; + +import java.math.BigInteger; + +/** + * Fill the missing judgments into the grade defined by `getDefaultGrade()`. This is an abstract + * class to dry code between static default grade and median default grade. + */ +public abstract class DefaultGradeTally extends Tally implements TallyInterface { + + /** Override this to choose the default grade for a given proposal. */ + protected abstract Integer getDefaultGradeForProposal(ProposalTallyInterface proposalTally); + + // /me is confused with why we need constructors in an abstract class? + + public DefaultGradeTally(TallyInterface tally) { + super(tally.getProposalsTallies(), tally.getAmountOfJudges()); + } + + public DefaultGradeTally(ProposalTallyInterface[] proposalsTallies, Integer amountOfJudges) { + super(proposalsTallies, amountOfJudges); + } + + public DefaultGradeTally(ProposalTallyInterface[] proposalsTallies, Long amountOfJudges) { + super(proposalsTallies, amountOfJudges); + } + + public DefaultGradeTally(ProposalTallyInterface[] proposalsTallies, BigInteger amountOfJudges) { + super(proposalsTallies, amountOfJudges); + } + + protected void fillWithDefaultGrade() { + int amountOfProposals = getAmountOfProposals(); + for (int i = 0; i < amountOfProposals; i++) { + ProposalTallyInterface proposalTally = getProposalsTallies()[i]; + Integer defaultGrade = getDefaultGradeForProposal(proposalTally); + BigInteger amountOfJudgments = proposalTally.getAmountOfJudgments(); + BigInteger missingAmount = this.amountOfJudges.subtract(amountOfJudgments); + int missingSign = missingAmount.compareTo(BigInteger.ZERO); + assert (0 <= missingSign); // ERROR: More judgments than judges! + if (0 < missingSign) { + BigInteger[] rawTally = proposalTally.getTally(); + rawTally[defaultGrade] = rawTally[defaultGrade].add(missingAmount); + } + } + } +} diff --git a/src/main/java/fr/mieuxvoter/mj/DeliberatorInterface.java b/src/main/java/fr/mieuxvoter/mj/DeliberatorInterface.java new file mode 100644 index 0000000..12120ce --- /dev/null +++ b/src/main/java/fr/mieuxvoter/mj/DeliberatorInterface.java @@ -0,0 +1,18 @@ +package fr.mieuxvoter.mj; + +/** + * A Deliberator takes in a poll's Tally, which holds the amount of judgments of each grade received + * by each Proposal, and outputs that poll's Result, that is the final rank of each Proposal. + * + *

Ranks start at 1 ("best"), and increment towards "worst". Two proposal may share the same + * rank, in extreme equality cases. + * + *

This is the main API of this library. + * + *

See MajorityJudgmentDeliberator for an implementation. One could implement other deliberators, + * such as: - CentralJudgmentDeliberator - UsualJudgmentDeliberator + */ +public interface DeliberatorInterface { + + public ResultInterface deliberate(TallyInterface tally) throws InvalidTallyException; +} diff --git a/src/main/java/fr/mieuxvoter/mj/IncoherentTallyException.java b/src/main/java/fr/mieuxvoter/mj/IncoherentTallyException.java new file mode 100644 index 0000000..315c8d7 --- /dev/null +++ b/src/main/java/fr/mieuxvoter/mj/IncoherentTallyException.java @@ -0,0 +1,13 @@ +package fr.mieuxvoter.mj; + +/** Raised when the provided tally holds negative values, or infinity. */ +class IncoherentTallyException extends InvalidTallyException { + + private static final long serialVersionUID = 5858986651601202903L; + + @Override + public String getMessage() { + return ("The provided tally holds negative values, or infinity. " + + (null == super.getMessage() ? "" : super.getMessage())); + } +} diff --git a/src/main/java/fr/mieuxvoter/mj/InvalidTallyException.java b/src/main/java/fr/mieuxvoter/mj/InvalidTallyException.java new file mode 100644 index 0000000..b055758 --- /dev/null +++ b/src/main/java/fr/mieuxvoter/mj/InvalidTallyException.java @@ -0,0 +1,9 @@ +package fr.mieuxvoter.mj; + +import java.security.InvalidParameterException; + +/** Raised when the provided tally is invalid. */ +class InvalidTallyException extends InvalidParameterException { + + private static final long serialVersionUID = 3033391835216704620L; +} diff --git a/src/main/java/fr/mieuxvoter/mj/MajorityJudgmentDeliberator.java b/src/main/java/fr/mieuxvoter/mj/MajorityJudgmentDeliberator.java new file mode 100644 index 0000000..6123bb5 --- /dev/null +++ b/src/main/java/fr/mieuxvoter/mj/MajorityJudgmentDeliberator.java @@ -0,0 +1,197 @@ +package fr.mieuxvoter.mj; + +import java.math.BigInteger; +import java.util.Arrays; +import java.util.Comparator; + +/** + * Deliberate using Majority Judgment. + * + *

Sorts Proposals by their median Grade. When two proposals share the same median Grade, give + * reason to the largest group of people that did not give the median Grade. + * + *

This algorithm is score-based, for performance (and possible parallelization). Each Proposal + * gets a score, higher (lexicographically) is "better" (depends of the meaning of the Grades). We + * use Strings instead of Integers or raw Bits for the score. Improve if you feel like it and can + * benchmark things. + * + *

https://en.wikipedia.org/wiki/Majority_judgment + * https://fr.wikipedia.org/wiki/Jugement_majoritaire + * + *

Should this class be `final`? + */ +public final class MajorityJudgmentDeliberator implements DeliberatorInterface { + + protected boolean favorContestation = true; + protected boolean numerizeScore = false; + + public MajorityJudgmentDeliberator() {} + + public MajorityJudgmentDeliberator(boolean favorContestation) { + this.favorContestation = favorContestation; + } + + public MajorityJudgmentDeliberator(boolean favorContestation, boolean numerizeScore) { + this.favorContestation = favorContestation; + this.numerizeScore = numerizeScore; + } + + @Override + public ResultInterface deliberate(TallyInterface tally) throws InvalidTallyException { + checkTally(tally); + + ProposalTallyInterface[] tallies = tally.getProposalsTallies(); + BigInteger amountOfJudges = tally.getAmountOfJudges(); + Integer amountOfProposals = tally.getAmountOfProposals(); + + Result result = new Result(); + ProposalResult[] proposalResults = new ProposalResult[amountOfProposals]; + + // I. Compute the scores of each Proposal + for (int proposalIndex = 0; proposalIndex < amountOfProposals; proposalIndex++) { + ProposalTallyInterface proposalTally = tallies[proposalIndex]; + String score = computeScore(proposalTally, amountOfJudges); + ProposalTallyAnalysis analysis = + new ProposalTallyAnalysis(proposalTally, this.favorContestation); + ProposalResult proposalResult = new ProposalResult(); + proposalResult.setScore(score); + proposalResult.setAnalysis(analysis); + // proposalResult.setRank(???); // rank is computed below, AFTER the score pass + proposalResults[proposalIndex] = proposalResult; + } + + // II. Sort Proposals by score (lexicographical inverse) + ProposalResult[] proposalResultsSorted = proposalResults.clone(); + assert (proposalResultsSorted[0].hashCode() + == proposalResults[0].hashCode()); // we need a shallow clone + Arrays.sort( + proposalResultsSorted, + (Comparator) (p0, p1) -> p1.getScore().compareTo(p0.getScore())); + + // III. Attribute a rank to each Proposal + Integer rank = 1; + for (int proposalIndex = 0; proposalIndex < amountOfProposals; proposalIndex++) { + ProposalResult proposalResult = proposalResultsSorted[proposalIndex]; + Integer actualRank = rank; + if (proposalIndex > 0) { + ProposalResult proposalResultBefore = proposalResultsSorted[proposalIndex - 1]; + if (proposalResult.getScore().contentEquals(proposalResultBefore.getScore())) { + actualRank = proposalResultBefore.getRank(); + } + } + proposalResult.setRank(actualRank); + rank += 1; + } + + result.setProposalResults(proposalResults); + return result; + } + + protected void checkTally(TallyInterface tally) throws UnbalancedTallyException { + if (!isTallyCoherent(tally)) { + throw new IncoherentTallyException(); + } + if (!isTallyBalanced(tally)) { + throw new UnbalancedTallyException(); + } + } + + protected boolean isTallyCoherent(TallyInterface tally) { + boolean coherent = true; + for (ProposalTallyInterface proposalTally : tally.getProposalsTallies()) { + for (BigInteger gradeTally : proposalTally.getTally()) { + if (-1 == gradeTally.compareTo(BigInteger.ZERO)) { + coherent = false; // negative tallies are not coherent + } + } + } + + return coherent; + } + + protected boolean isTallyBalanced(TallyInterface tally) { + boolean balanced = true; + BigInteger amountOfJudges = BigInteger.ZERO; + boolean firstProposal = true; + for (ProposalTallyInterface proposalTally : tally.getProposalsTallies()) { + if (firstProposal) { + amountOfJudges = proposalTally.getAmountOfJudgments(); + firstProposal = false; + } else { + if (0 != amountOfJudges.compareTo(proposalTally.getAmountOfJudgments())) { + balanced = false; + } + } + } + + return balanced; + } + + /** @see computeScore() below */ + protected String computeScore(ProposalTallyInterface tally, BigInteger amountOfJudges) { + return computeScore(tally, amountOfJudges, this.favorContestation, this.numerizeScore); + } + + /** + * A higher score means a better rank. Assumes that grades' tallies are provided from "worst" + * grade to "best" grade. + * + * @param tally Holds the tallies of each Grade for a single Proposal + * @param amountOfJudges + * @param favorContestation Use the lower median, for example + * @param onlyNumbers Do not use separation characters, match `^[0-9]+$` + * @return the score of the proposal + */ + protected String computeScore( + ProposalTallyInterface tally, + BigInteger amountOfJudges, + Boolean favorContestation, + Boolean onlyNumbers) { + ProposalTallyAnalysis analysis = new ProposalTallyAnalysis(); + int amountOfGrades = tally.getTally().length; + int digitsForGrade = countDigits(amountOfGrades); + int digitsForGroup = countDigits(amountOfJudges) + 1; + + ProposalTallyInterface currentTally = tally.duplicate(); + + String score = ""; + for (int i = 0; i < amountOfGrades; i++) { + + analysis.reanalyze(currentTally, favorContestation); + + if (0 < i && !onlyNumbers) { + score += "/"; + } + + score += String.format("%0" + digitsForGrade + "d", analysis.getMedianGrade()); + + if (!onlyNumbers) { + score += "_"; + } + + score += + String.format( + "%0" + digitsForGroup + "d", + // We offset by amountOfJudges to keep a lexicographical order (no + // negatives) + // amountOfJudges + secondMedianGroupSize * secondMedianGroupSign + amountOfJudges.add( + analysis.getSecondMedianGroupSize() + .multiply( + BigInteger.valueOf( + analysis.getSecondMedianGroupSign())))); + + currentTally.moveJudgments(analysis.getMedianGrade(), analysis.getSecondMedianGrade()); + } + + return score; + } + + protected int countDigits(int number) { + return ("" + number).length(); + } + + protected int countDigits(BigInteger number) { + return ("" + number).length(); + } +} diff --git a/src/main/java/fr/mieuxvoter/mj/MedianDefaultTally.java b/src/main/java/fr/mieuxvoter/mj/MedianDefaultTally.java new file mode 100644 index 0000000..1b6c47a --- /dev/null +++ b/src/main/java/fr/mieuxvoter/mj/MedianDefaultTally.java @@ -0,0 +1,37 @@ +package fr.mieuxvoter.mj; + +import java.math.BigInteger; + +/** + * Fill the missing judgments into the median grade of each proposal. Useful when the proposals have + * not received the exact same amount of votes and the median grade is considered a sane default. + */ +public class MedianDefaultTally extends DefaultGradeTally implements TallyInterface { + + public MedianDefaultTally(TallyInterface tally) { + super(tally.getProposalsTallies(), tally.getAmountOfJudges()); + fillWithDefaultGrade(); + } + + public MedianDefaultTally( + ProposalTallyInterface[] proposalsTallies, BigInteger amountOfJudges) { + super(proposalsTallies, amountOfJudges); + fillWithDefaultGrade(); + } + + public MedianDefaultTally(ProposalTallyInterface[] proposalsTallies, Long amountOfJudges) { + super(proposalsTallies, amountOfJudges); + fillWithDefaultGrade(); + } + + public MedianDefaultTally(ProposalTallyInterface[] proposalsTallies, Integer amountOfJudges) { + super(proposalsTallies, amountOfJudges); + fillWithDefaultGrade(); + } + + @Override + protected Integer getDefaultGradeForProposal(ProposalTallyInterface proposalTally) { + ProposalTallyAnalysis analysis = new ProposalTallyAnalysis(proposalTally); + return analysis.getMedianGrade(); + } +} diff --git a/src/main/java/fr/mieuxvoter/mj/NormalizedTally.java b/src/main/java/fr/mieuxvoter/mj/NormalizedTally.java new file mode 100644 index 0000000..bab5224 --- /dev/null +++ b/src/main/java/fr/mieuxvoter/mj/NormalizedTally.java @@ -0,0 +1,75 @@ +package fr.mieuxvoter.mj; + +import java.math.BigInteger; +import java.security.InvalidParameterException; + +/** + * The deliberator expects the proposals' tallies to hold the same amount of judgments. This + * NormalizedTally accepts tallies with disparate amounts of judgments per proposal, and normalizes + * them to their least common multiple, which amounts to using percentages, except we don't use + * floating-point arithmetic. + * + *

This is useful when there are too many proposals for judges to be expected to judge them all, + * and all the proposals received reasonably similar amounts of judgments. + */ +public class NormalizedTally extends Tally implements TallyInterface { + + public NormalizedTally(ProposalTallyInterface[] proposalsTallies) { + super(proposalsTallies); + initializeFromProposalsTallies(proposalsTallies); + } + + public NormalizedTally(TallyInterface tally) { + super(tally.getProposalsTallies()); + initializeFromProposalsTallies(tally.getProposalsTallies()); + } + + protected void initializeFromProposalsTallies(ProposalTallyInterface[] proposalsTallies) { + Integer amountOfProposals = getAmountOfProposals(); + + // Compute the Least Common Multiple + BigInteger amountOfJudges = BigInteger.ONE; + for (ProposalTallyInterface proposalTally : proposalsTallies) { + amountOfJudges = lcm(amountOfJudges, proposalTally.getAmountOfJudgments()); + } + + if (0 == amountOfJudges.compareTo(BigInteger.ZERO)) { + throw new InvalidParameterException( + "Cannot normalize: one or more proposals have no judgments."); + } + + // Normalize proposals to the LCM + ProposalTally[] normalizedTallies = new ProposalTally[amountOfProposals]; + for (int i = 0; i < amountOfProposals; i++) { + ProposalTallyInterface proposalTally = proposalsTallies[i]; + ProposalTally normalizedTally = new ProposalTally(proposalTally); + BigInteger factor = amountOfJudges.divide(proposalTally.getAmountOfJudgments()); + Integer amountOfGrades = proposalTally.getTally().length; + BigInteger[] gradesTallies = normalizedTally.getTally(); + for (int j = 0; j < amountOfGrades; j++) { + gradesTallies[j] = gradesTallies[j].multiply(factor); + } + normalizedTallies[i] = normalizedTally; + } + + setProposalsTallies(normalizedTallies); + setAmountOfJudges(amountOfJudges); + } + + /** + * Least Common Multiple + * + *

http://en.wikipedia.org/wiki/Least_common_multiple + * + *

lcm( 6, 9 ) = 18 lcm( 4, 9 ) = 36 lcm( 0, 9 ) = 0 lcm( 0, 0 ) = 0 + * + * @author www.java2s.com + * @param a first integer + * @param b second integer + * @return least common multiple of a and b + */ + public static BigInteger lcm(BigInteger a, BigInteger b) { + if (a.signum() == 0 || b.signum() == 0) return BigInteger.ZERO; + return a.divide(a.gcd(b)).multiply(b).abs(); + } +} diff --git a/src/main/java/fr/mieuxvoter/mj/ProposalResult.java b/src/main/java/fr/mieuxvoter/mj/ProposalResult.java new file mode 100644 index 0000000..8e04165 --- /dev/null +++ b/src/main/java/fr/mieuxvoter/mj/ProposalResult.java @@ -0,0 +1,34 @@ +package fr.mieuxvoter.mj; + +public class ProposalResult implements ProposalResultInterface { + + protected Integer rank; + + protected String score; + + protected ProposalTallyAnalysis analysis; + + public Integer getRank() { + return rank; + } + + public void setRank(Integer rank) { + this.rank = rank; + } + + public String getScore() { + return score; + } + + public void setScore(String score) { + this.score = score; + } + + public ProposalTallyAnalysis getAnalysis() { + return analysis; + } + + public void setAnalysis(ProposalTallyAnalysis analysis) { + this.analysis = analysis; + } +} diff --git a/src/main/java/fr/mieuxvoter/mj/ProposalResultInterface.java b/src/main/java/fr/mieuxvoter/mj/ProposalResultInterface.java new file mode 100644 index 0000000..d956a42 --- /dev/null +++ b/src/main/java/fr/mieuxvoter/mj/ProposalResultInterface.java @@ -0,0 +1,21 @@ +package fr.mieuxvoter.mj; + +public interface ProposalResultInterface { + + /** + * Rank starts at 1 ("best" proposal), and goes upwards. Multiple Proposals may receive the same + * rank, in the extreme case where they received the exact same judgments, or the same judgment + * repartition in normalized tallies. + */ + public Integer getRank(); + + /** + * This score was used to compute the rank. It is made of integer characters, with zeroes for + * padding. Inverse lexicographical order: "higher" is "better". You're probably never going to + * need this, but it's here anyway. + */ + public String getScore(); + + /** Get more data about the proposal tally, such as the median grade. */ + public ProposalTallyAnalysis getAnalysis(); +} diff --git a/src/main/java/fr/mieuxvoter/mj/ProposalTally.java b/src/main/java/fr/mieuxvoter/mj/ProposalTally.java new file mode 100644 index 0000000..1e1ee45 --- /dev/null +++ b/src/main/java/fr/mieuxvoter/mj/ProposalTally.java @@ -0,0 +1,92 @@ +package fr.mieuxvoter.mj; + +import java.math.BigInteger; +import java.util.Arrays; + +public class ProposalTally implements ProposalTallyInterface { + + /** + * Amounts of judgments received per grade, from "worst" grade to "best" grade. Those are + * BigIntegers because of our LCM-based normalization shenanigans. + */ + protected BigInteger[] tally; + + public ProposalTally() {} + + public ProposalTally(String[] tally) { + setTally(tally); + } + + public ProposalTally(Integer[] tally) { + setTally(tally); + } + + public ProposalTally(Long[] tally) { + setTally(tally); + } + + public ProposalTally(BigInteger[] tally) { + setTally(tally); + } + + public ProposalTally(ProposalTallyInterface proposalTally) { + setTally(Arrays.copyOf(proposalTally.getTally(), proposalTally.getTally().length)); + } + + public void setTally(String[] tally) { + int tallyLength = tally.length; + BigInteger[] bigTally = new BigInteger[tallyLength]; + for (int i = 0; i < tallyLength; i++) { + bigTally[i] = new BigInteger(tally[i]); + } + setTally(bigTally); + } + + public void setTally(Integer[] tally) { + int tallyLength = tally.length; + BigInteger[] bigTally = new BigInteger[tallyLength]; + for (int i = 0; i < tallyLength; i++) { + bigTally[i] = BigInteger.valueOf(tally[i]); + } + setTally(bigTally); + } + + public void setTally(Long[] tally) { + int tallyLength = tally.length; + BigInteger[] bigTally = new BigInteger[tallyLength]; + for (int i = 0; i < tallyLength; i++) { + bigTally[i] = BigInteger.valueOf(tally[i]); + } + setTally(bigTally); + } + + public void setTally(BigInteger[] tally) { + this.tally = tally; + } + + @Override + public BigInteger[] getTally() { + return this.tally; + } + + @Override + public ProposalTallyInterface duplicate() { + return new ProposalTally(Arrays.copyOf(this.tally, this.tally.length)); + } + + @Override + public void moveJudgments(Integer fromGrade, Integer intoGrade) { + this.tally[intoGrade] = this.tally[intoGrade].add(this.tally[fromGrade]); + this.tally[fromGrade] = BigInteger.ZERO; + } + + @Override + public BigInteger getAmountOfJudgments() { + BigInteger sum = BigInteger.ZERO; + int tallyLength = this.tally.length; + for (int i = 0; i < tallyLength; i++) { + sum = sum.add(this.tally[i]); + } + return sum; + } +} diff --git a/src/main/java/fr/mieuxvoter/mj/ProposalTallyAnalysis.java b/src/main/java/fr/mieuxvoter/mj/ProposalTallyAnalysis.java new file mode 100644 index 0000000..84da69d --- /dev/null +++ b/src/main/java/fr/mieuxvoter/mj/ProposalTallyAnalysis.java @@ -0,0 +1,179 @@ +package fr.mieuxvoter.mj; + +import java.math.BigInteger; + +/** + * Collect useful data on a proposal tally. Does NOT compute the rank, but provides all we need. + * + *

This uses BigInteger because in a normalization scenario we use the smallest common multiple + * of the amounts of judges of proposals. It makes the code harder to read and understand, but it + * allows us to bypass the floating-point nightmare of the normalization of merit profiles, which is + * one way to handle default grades on some polls. + */ +public class ProposalTallyAnalysis { + + protected ProposalTallyInterface tally; + + protected BigInteger totalSize = BigInteger.ZERO; // amount of judges + + protected Integer medianGrade = 0; + + protected BigInteger medianGroupSize = BigInteger.ZERO; // amount of judges in the median group + + protected Integer contestationGrade = 0; // "best" grade of the contestation group + + protected BigInteger contestationGroupSize = BigInteger.ZERO; // of lower grades than median + + protected Integer adhesionGrade = 0; // "worst" grade of the adhesion group + + protected BigInteger adhesionGroupSize = BigInteger.ZERO; // of higher grades than median + + protected Integer secondMedianGrade = 0; // grade of the biggest group out of the median + + protected BigInteger secondMedianGroupSize = BigInteger.ZERO; // either contestation or adhesion + + protected Integer secondMedianGroupSign = + 0; // -1 for contestation, +1 for adhesion, 0 for empty group size + + public ProposalTallyAnalysis() {} + + public ProposalTallyAnalysis(ProposalTallyInterface tally) { + reanalyze(tally); + } + + public ProposalTallyAnalysis(ProposalTallyInterface tally, Boolean favorContestation) { + reanalyze(tally, favorContestation); + } + + public void reanalyze(ProposalTallyInterface tally) { + reanalyze(tally, true); + } + + public void reanalyze(ProposalTallyInterface tally, Boolean favorContestation) { + this.tally = tally; + this.totalSize = BigInteger.ZERO; + this.medianGrade = 0; + this.medianGroupSize = BigInteger.ZERO; + this.contestationGrade = 0; + this.contestationGroupSize = BigInteger.ZERO; + this.adhesionGrade = 0; + this.adhesionGroupSize = BigInteger.ZERO; + this.secondMedianGrade = 0; + this.secondMedianGroupSize = BigInteger.ZERO; + this.secondMedianGroupSign = 0; + + BigInteger[] gradesTallies = this.tally.getTally(); + int amountOfGrades = gradesTallies.length; + + for (int grade = 0; grade < amountOfGrades; grade++) { + BigInteger gradeTally = gradesTallies[grade]; + // assert(0 <= gradeTally); // Negative tallies are not allowed. + this.totalSize = this.totalSize.add(gradeTally); + } + + Integer medianOffset = 1; + if (!favorContestation) { + medianOffset = 2; + } + BigInteger medianCursor = + this.totalSize.add(BigInteger.valueOf(medianOffset)).divide(BigInteger.valueOf(2)); + // Long medianCursor = (long) Math.floor((this.totalSize + medianOffset) / 2.0); + + BigInteger tallyBeforeCursor = BigInteger.ZERO; + BigInteger tallyCursor = BigInteger.ZERO; + Boolean foundMedian = false; + Integer contestationGrade = 0; + Integer adhesionGrade = 0; + for (int grade = 0; grade < amountOfGrades; grade++) { + BigInteger gradeTally = gradesTallies[grade]; + tallyBeforeCursor = tallyCursor; + tallyCursor = tallyCursor.add(gradeTally); + + if (!foundMedian) { + if (-1 < tallyCursor.compareTo(medianCursor)) { // tallyCursor >= medianCursor + foundMedian = true; + this.medianGrade = grade; + this.contestationGroupSize = tallyBeforeCursor; + this.medianGroupSize = gradeTally; + this.adhesionGroupSize = + this.totalSize + .subtract(this.contestationGroupSize) + .subtract(this.medianGroupSize); + } else { + if (1 == gradeTally.compareTo(BigInteger.ZERO)) { // 0 < gradeTally + contestationGrade = grade; + } + } + } else { + if (1 == gradeTally.compareTo(BigInteger.ZERO) && 0 == adhesionGrade) { + adhesionGrade = grade; + } + } + } + + this.contestationGrade = contestationGrade; + this.adhesionGrade = adhesionGrade; + this.secondMedianGroupSize = this.contestationGroupSize.max(this.adhesionGroupSize); + this.secondMedianGroupSign = 0; + // if (this.contestationGroupSize < this.adhesionGroupSize) { + if (1 == this.adhesionGroupSize.compareTo(this.contestationGroupSize)) { + this.secondMedianGrade = this.adhesionGrade; + this.secondMedianGroupSign = 1; + // } else if (this.contestationGroupSize > this.adhesionGroupSize) { + } else if (1 == this.contestationGroupSize.compareTo(this.adhesionGroupSize)) { + this.secondMedianGrade = this.contestationGrade; + this.secondMedianGroupSign = -1; + } else { + if (favorContestation) { + this.secondMedianGrade = this.contestationGrade; + this.secondMedianGroupSign = -1; + } else { + this.secondMedianGrade = this.adhesionGrade; + this.secondMedianGroupSign = 1; + } + } + if (0 == this.secondMedianGroupSize.compareTo(BigInteger.ZERO)) { + this.secondMedianGroupSign = 0; + } + } + + public BigInteger getTotalSize() { + return totalSize; + } + + public Integer getMedianGrade() { + return medianGrade; + } + + public BigInteger getMedianGroupSize() { + return medianGroupSize; + } + + public Integer getContestationGrade() { + return contestationGrade; + } + + public BigInteger getContestationGroupSize() { + return contestationGroupSize; + } + + public Integer getAdhesionGrade() { + return adhesionGrade; + } + + public BigInteger getAdhesionGroupSize() { + return adhesionGroupSize; + } + + public Integer getSecondMedianGrade() { + return secondMedianGrade; + } + + public BigInteger getSecondMedianGroupSize() { + return secondMedianGroupSize; + } + + public Integer getSecondMedianGroupSign() { + return secondMedianGroupSign; + } +} diff --git a/src/main/java/fr/mieuxvoter/mj/ProposalTallyInterface.java b/src/main/java/fr/mieuxvoter/mj/ProposalTallyInterface.java new file mode 100644 index 0000000..e0f23b4 --- /dev/null +++ b/src/main/java/fr/mieuxvoter/mj/ProposalTallyInterface.java @@ -0,0 +1,29 @@ +package fr.mieuxvoter.mj; + +import java.math.BigInteger; + +/** + * Also known as the merit profile of a proposal (aka. candidate), this holds the amounts of + * judgments received per grade. + */ +public interface ProposalTallyInterface { + + /** + * The tallies of each Grade, that is the amount of judgments received for each Grade by the + * Proposal, from "worst" ("most conservative") Grade to "best" Grade. + */ + public BigInteger[] getTally(); + + /** + * Should be the sum of getTally() + * + * @return The total amount of judgments received by this proposal. + */ + public BigInteger getAmountOfJudgments(); + + /** Homemade factory to skip the clone() shenanigans. Used by the score calculus. */ + public ProposalTallyInterface duplicate(); + + /** Move judgments that were fromGrade into intoGrade. Used by the score calculus. */ + public void moveJudgments(Integer fromGrade, Integer intoGrade); +} diff --git a/src/main/java/fr/mieuxvoter/mj/Result.java b/src/main/java/fr/mieuxvoter/mj/Result.java new file mode 100644 index 0000000..7acca07 --- /dev/null +++ b/src/main/java/fr/mieuxvoter/mj/Result.java @@ -0,0 +1,14 @@ +package fr.mieuxvoter.mj; + +public class Result implements ResultInterface { + + protected ProposalResultInterface[] proposalResults; + + public ProposalResultInterface[] getProposalResults() { + return proposalResults; + } + + public void setProposalResults(ProposalResultInterface[] proposalResults) { + this.proposalResults = proposalResults; + } +} diff --git a/src/main/java/fr/mieuxvoter/mj/ResultInterface.java b/src/main/java/fr/mieuxvoter/mj/ResultInterface.java new file mode 100644 index 0000000..06e163a --- /dev/null +++ b/src/main/java/fr/mieuxvoter/mj/ResultInterface.java @@ -0,0 +1,12 @@ +package fr.mieuxvoter.mj; + +public interface ResultInterface { + + /** + * ProposalResults are not ordered by rank, they are in the order the proposals' tallies were + * submitted. + * + * @return an array of `ProposalResult`, in the order the `ProposalTally`s were submitted. + */ + public ProposalResultInterface[] getProposalResults(); +} diff --git a/src/main/java/fr/mieuxvoter/mj/StaticDefaultTally.java b/src/main/java/fr/mieuxvoter/mj/StaticDefaultTally.java new file mode 100644 index 0000000..80b6a86 --- /dev/null +++ b/src/main/java/fr/mieuxvoter/mj/StaticDefaultTally.java @@ -0,0 +1,53 @@ +package fr.mieuxvoter.mj; + +import java.math.BigInteger; + +public class StaticDefaultTally extends DefaultGradeTally implements TallyInterface { + + /** + * Grades are represented as numbers, as indices in a list. Grades start from 0 ("worst" grade, + * most conservative) and go upwards. Values out of the range of grades defined in the tally + * will yield errors. + * + *

Example: + * + *

0 == REJECT 1 == PASSABLE 2 == GOOD 3 == EXCELLENT + */ + protected Integer defaultGrade = 0; + + public StaticDefaultTally(TallyInterface tally, Integer defaultGrade) { + super(tally.getProposalsTallies(), tally.getAmountOfJudges()); + this.defaultGrade = defaultGrade; + fillWithDefaultGrade(); + } + + public StaticDefaultTally( + ProposalTallyInterface[] proposalsTallies, + BigInteger amountOfJudges, + Integer defaultGrade) { + super(proposalsTallies, amountOfJudges); + this.defaultGrade = defaultGrade; + fillWithDefaultGrade(); + } + + public StaticDefaultTally( + ProposalTallyInterface[] proposalsTallies, Long amountOfJudges, Integer defaultGrade) { + super(proposalsTallies, amountOfJudges); + this.defaultGrade = defaultGrade; + fillWithDefaultGrade(); + } + + public StaticDefaultTally( + ProposalTallyInterface[] proposalsTallies, + Integer amountOfJudges, + Integer defaultGrade) { + super(proposalsTallies, amountOfJudges); + this.defaultGrade = defaultGrade; + fillWithDefaultGrade(); + } + + @Override + protected Integer getDefaultGradeForProposal(ProposalTallyInterface proposalTally) { + return this.defaultGrade; + } +} diff --git a/src/main/java/fr/mieuxvoter/mj/Tally.java b/src/main/java/fr/mieuxvoter/mj/Tally.java new file mode 100644 index 0000000..0331a0d --- /dev/null +++ b/src/main/java/fr/mieuxvoter/mj/Tally.java @@ -0,0 +1,61 @@ +package fr.mieuxvoter.mj; + +import java.math.BigInteger; + +/** + * A Basic implementation of a TallyInterface that reads from an array of ProposalTallyInterface. + */ +public class Tally implements TallyInterface { + + protected ProposalTallyInterface[] proposalsTallies; + + protected BigInteger amountOfJudges = BigInteger.ZERO; + + public Tally(ProposalTallyInterface[] proposalsTallies) { + setProposalsTallies(proposalsTallies); + guessAmountOfJudges(); + } + + public Tally(ProposalTallyInterface[] proposalsTallies, BigInteger amountOfJudges) { + setProposalsTallies(proposalsTallies); + setAmountOfJudges(amountOfJudges); + } + + public Tally(ProposalTallyInterface[] proposalsTallies, Long amountOfJudges) { + setProposalsTallies(proposalsTallies); + setAmountOfJudges(BigInteger.valueOf(amountOfJudges)); + } + + public Tally(ProposalTallyInterface[] proposalsTallies, Integer amountOfJudges) { + setProposalsTallies(proposalsTallies); + setAmountOfJudges(BigInteger.valueOf(amountOfJudges)); + } + + public ProposalTallyInterface[] getProposalsTallies() { + return proposalsTallies; + } + + public void setProposalsTallies(ProposalTallyInterface[] proposalsTallies) { + this.proposalsTallies = proposalsTallies; + } + + public Integer getAmountOfProposals() { + return proposalsTallies.length; + } + + public BigInteger getAmountOfJudges() { + return amountOfJudges; + } + + public void setAmountOfJudges(BigInteger amountOfJudges) { + this.amountOfJudges = amountOfJudges; + } + + protected void guessAmountOfJudges() { + BigInteger amountOfJudges = BigInteger.ZERO; + for (ProposalTallyInterface proposalTally : getProposalsTallies()) { + amountOfJudges = proposalTally.getAmountOfJudgments().max(amountOfJudges); + } + setAmountOfJudges(amountOfJudges); + } +} diff --git a/src/main/java/fr/mieuxvoter/mj/TallyInterface.java b/src/main/java/fr/mieuxvoter/mj/TallyInterface.java new file mode 100644 index 0000000..33a41f1 --- /dev/null +++ b/src/main/java/fr/mieuxvoter/mj/TallyInterface.java @@ -0,0 +1,12 @@ +package fr.mieuxvoter.mj; + +import java.math.BigInteger; + +public interface TallyInterface { + + public ProposalTallyInterface[] getProposalsTallies(); + + public BigInteger getAmountOfJudges(); + + public Integer getAmountOfProposals(); +} diff --git a/src/main/java/fr/mieuxvoter/mj/UnbalancedTallyException.java b/src/main/java/fr/mieuxvoter/mj/UnbalancedTallyException.java new file mode 100644 index 0000000..abd590d --- /dev/null +++ b/src/main/java/fr/mieuxvoter/mj/UnbalancedTallyException.java @@ -0,0 +1,20 @@ +package fr.mieuxvoter.mj; + +/** + * Raised when the provided tally does not hold the same amount of judgments for each proposal, and + * normalization is required. + */ +class UnbalancedTallyException extends InvalidTallyException { + + private static final long serialVersionUID = 5041093000505081735L; + + @Override + public String getMessage() { + return ("The provided tally is unbalanced, as some proposals received more judgments than" + + " others. \n" + + "You need to set a strategy for balancing tallies. To that effect, \n" + + "you may use StaticDefaultTally, MedianDefaultTally, or NormalizedTally" + + " instead of Tally. \n" + + (null == super.getMessage() ? "" : super.getMessage())); + } +}